diff --git a/gotham_restful/Cargo.toml b/gotham_restful/Cargo.toml index bb9bd45..512cbb1 100644 --- a/gotham_restful/Cargo.toml +++ b/gotham_restful/Cargo.toml @@ -25,18 +25,19 @@ gotham_derive = { git = "https://github.com/gotham-rs/gotham", version = "0.5.0- gotham_middleware_diesel = { git = "https://github.com/gotham-rs/gotham", version = "0.1.0", optional = true } gotham_restful_derive = { version = "0.0.4-dev" } indexmap = { version = "1.3.2", optional = true } +itertools = "0.9.0" jsonwebtoken = { version = "7.1.0", optional = true } log = { version = "0.4.8", optional = true } mime = "0.3.16" openapiv3 = { version = "0.3", optional = true } serde = { version = "1.0.106", features = ["derive"] } serde_json = "1.0.51" +thiserror = "1.0.15" uuid = { version = ">= 0.1, < 0.9", optional = true } [dev-dependencies] futures-executor = "0.3.4" paste = "0.1.10" -thiserror = "1" [features] default = [] diff --git a/gotham_restful/src/lib.rs b/gotham_restful/src/lib.rs index a31247c..ad19175 100644 --- a/gotham_restful/src/lib.rs +++ b/gotham_restful/src/lib.rs @@ -151,6 +151,8 @@ pub use auth::{ StaticAuthHandler }; +pub mod matcher; + #[cfg(feature = "openapi")] mod openapi; #[cfg(feature = "openapi")] diff --git a/gotham_restful/src/matcher/accept.rs b/gotham_restful/src/matcher/accept.rs new file mode 100644 index 0000000..82e0fed --- /dev/null +++ b/gotham_restful/src/matcher/accept.rs @@ -0,0 +1,214 @@ +use super::{LookupTable, LookupTableFromTypes}; +use gotham::{ + hyper::{ + header::{HeaderMap, ACCEPT}, + StatusCode + }, + router::{non_match::RouteNonMatch, route::matcher::RouteMatcher}, + state::{FromState, State} +}; +use mime::Mime; +use std::{ + num::ParseFloatError, + str::FromStr +}; +use thiserror::Error; + + +/// A mime type that is optionally weighted with a quality. +#[derive(Debug)] +struct QMime +{ + mime : Mime, + weight : Option +} + +impl QMime +{ + fn new(mime : Mime, weight : Option) -> Self + { + Self { mime, weight } + } +} + +#[derive(Debug, Error)] +enum QMimeError +{ + #[error("Unable to parse mime type: {0}")] + MimeError(#[from] mime::FromStrError), + #[error("Unable to parse mime quality: {0}")] + NumError(#[from] ParseFloatError) +} + +impl FromStr for QMime +{ + type Err = QMimeError; + + fn from_str(str : &str) -> Result + { + match str.find(";q=") { + None => Ok(Self::new(str.parse()?, None)), + Some(index) => { + let mime = str[..index].parse()?; + let weight = str[index+3..].parse()?; + Ok(Self::new(mime, Some(weight))) + } + } + } +} + + +/** +A route matcher that checks for the presence of a supported content type. + +Usage: + +``` +# use gotham::{helpers::http::response::create_response, hyper::StatusCode, router::builder::*}; +# use gotham_restful::matcher::ContentTypeMatcher; +# +let types = vec![mime::TEXT_HTML, mime::TEXT_PLAIN]; +let matcher = ContentTypeMatcher::new(types) + // optionally accept requests with no content type + .allow_no_type(); + +# build_simple_router(|route| { +// use the matcher for your request +route.post("/foo") + .extend_route_matcher(matcher) + .to(|state| { + let res = create_response(&state, StatusCode::OK, mime::TEXT_PLAIN, "Correct Content Type!"); + (state, res) + }); +# }); +``` +*/ +#[derive(Clone)] +pub struct AcceptHeaderMatcher +{ + types : Vec, + lookup_table : LookupTable +} + +impl AcceptHeaderMatcher +{ + /// Create a new `AcceptHeaderMatcher` with the given types that can be produced by the route. + pub fn new(types : Vec) -> Self + { + let lookup_table = LookupTable::from_types(types.iter(), true); + Self { types, lookup_table } + } +} + +#[inline] +fn err() -> RouteNonMatch +{ + RouteNonMatch::new(StatusCode::NOT_ACCEPTABLE) +} + +impl RouteMatcher for AcceptHeaderMatcher +{ + fn is_match(&self, state : &State) -> Result<(), RouteNonMatch> + { + HeaderMap::borrow_from(state).get(ACCEPT) + .map(|header| { + // parse mime types from the accept header + let acceptable = header.to_str() + .map_err(|_| err())? + .split(',') + .map(|str| str.trim().parse()) + .collect::, _>>() + .map_err(|_| err())?; + + for qmime in acceptable + { + // get mime type candidates from the lookup table + let essence = qmime.mime.essence_str(); + let candidates = match self.lookup_table.get(essence) { + Some(candidates) => candidates, + None => continue + }; + for i in candidates + { + let candidate = &self.types[*i]; + + // check that the candidates have the same suffix - this is not included in the + // essence string + if candidate.suffix() != qmime.mime.suffix() + { + continue + } + + // this candidate matches - params don't play a role in accept header matching + return Ok(()) + } + } + + // no candidates found + Err(err()) + }).unwrap_or_else(|| { + // no accept header - assume all types are acceptable + Ok(()) + }) + } +} + + +#[cfg(test)] +mod test +{ + use super::*; + + fn with_state(accept : Option<&str>, block : F) + where F : FnOnce(&mut State) -> () + { + State::with_new(|state| { + let mut headers = HeaderMap::new(); + if let Some(acc) = accept + { + headers.insert(ACCEPT, acc.parse().unwrap()); + } + state.put(headers); + block(state); + }); + } + + #[test] + fn no_accept_header() + { + let matcher = AcceptHeaderMatcher::new(vec!(mime::TEXT_PLAIN)); + with_state(None, |state| assert!(matcher.is_match(&state).is_ok())); + } + + #[test] + fn single_mime_type() + { + let matcher = AcceptHeaderMatcher::new(vec!(mime::TEXT_PLAIN, mime::IMAGE_PNG)); + with_state(Some("text/plain"), |state| assert!(matcher.is_match(&state).is_ok())); + with_state(Some("text/html"), |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("image/png"), |state| assert!(matcher.is_match(&state).is_ok())); + with_state(Some("image/webp"), |state| assert!(matcher.is_match(&state).is_err())); + } + + #[test] + fn star_star() + { + let matcher = AcceptHeaderMatcher::new(vec!(mime::IMAGE_PNG)); + with_state(Some("*/*"), |state| assert!(matcher.is_match(&state).is_ok())); + } + + #[test] + fn image_star() + { + let matcher = AcceptHeaderMatcher::new(vec!(mime::IMAGE_PNG)); + with_state(Some("image/*"), |state| assert!(matcher.is_match(&state).is_ok())); + } + + #[test] + fn complex_header() + { + let matcher = AcceptHeaderMatcher::new(vec!(mime::IMAGE_PNG)); + with_state(Some("text/html,image/webp;q=0.8"), |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("text/html,image/webp;q=0.8,*/*;q=0.1"), |state| assert!(matcher.is_match(&state).is_ok())); + } +} diff --git a/gotham_restful/src/matcher/content_type.rs b/gotham_restful/src/matcher/content_type.rs new file mode 100644 index 0000000..cb55571 --- /dev/null +++ b/gotham_restful/src/matcher/content_type.rs @@ -0,0 +1,173 @@ +use super::{LookupTable, LookupTableFromTypes}; +use gotham::{ + hyper::{ + header::{HeaderMap, CONTENT_TYPE}, + StatusCode + }, + router::{non_match::RouteNonMatch, route::matcher::RouteMatcher}, + state::{FromState, State} +}; +use mime::Mime; + +/** +A route matcher that checks for the presence of a supported content type. + +Usage: + +``` +# use gotham::{helpers::http::response::create_response, hyper::StatusCode, router::builder::*}; +# use gotham_restful::matcher::ContentTypeMatcher; +# +let types = vec![mime::TEXT_HTML, mime::TEXT_PLAIN]; +let matcher = ContentTypeMatcher::new(types) + // optionally accept requests with no content type + .allow_no_type(); + +# build_simple_router(|route| { +// use the matcher for your request +route.post("/foo") + .extend_route_matcher(matcher) + .to(|state| { + let res = create_response(&state, StatusCode::OK, mime::TEXT_PLAIN, "Correct Content Type!"); + (state, res) + }); +# }); +``` +*/ +#[derive(Clone)] +pub struct ContentTypeMatcher +{ + types : Vec, + lookup_table : LookupTable, + allow_no_type : bool +} + +impl ContentTypeMatcher +{ + /// Create a new `ContentTypeMatcher` with the given supported types that does not allow requests + /// that don't include a content-type header. + pub fn new(types : Vec) -> Self + { + let lookup_table = LookupTable::from_types(types.iter(), false); + Self { types, lookup_table, allow_no_type: false } + } + + /// Modify this matcher to allow requests that don't include a content-type header. + pub fn allow_no_type(mut self) -> Self + { + self.allow_no_type = true; + self + } +} + +#[inline] +fn err() -> RouteNonMatch +{ + RouteNonMatch::new(StatusCode::UNSUPPORTED_MEDIA_TYPE) +} + +impl RouteMatcher for ContentTypeMatcher +{ + fn is_match(&self, state : &State) -> Result<(), RouteNonMatch> + { + HeaderMap::borrow_from(state).get(CONTENT_TYPE) + .map(|ty| { + // parse mime type from the content type header + let mime : Mime = ty.to_str() + .map_err(|_| err())? + .parse() + .map_err(|_| err())?; + + // get mime type candidates from the lookup table + let essence = mime.essence_str(); + let candidates = self.lookup_table.get(essence).ok_or_else(err)?; + for i in candidates + { + let candidate = &self.types[*i]; + + // check that the candidates have the same suffix - this is not included in the + // essence string + if candidate.suffix() != mime.suffix() + { + continue + } + + // check that this candidate has at least the parameters that the content type + // has and that their values are equal + if candidate.params().any(|(key, value)| mime.get_param(key) != Some(value)) + { + continue + } + + // this candidate matches + return Ok(()) + } + + // no candidates found + Err(err()) + }).unwrap_or_else(|| { + // no type present + if self.allow_no_type { Ok(()) } else { Err(err()) } + }) + } +} + + +#[cfg(test)] +mod test +{ + use super::*; + + fn with_state(content_type : Option<&str>, block : F) + where F : FnOnce(&mut State) -> () + { + State::with_new(|state| { + let mut headers = HeaderMap::new(); + if let Some(ty) = content_type + { + headers.insert(CONTENT_TYPE, ty.parse().unwrap()); + } + state.put(headers); + block(state); + }); + } + + #[test] + fn empty_type_list() + { + let matcher = ContentTypeMatcher::new(Vec::new()); + with_state(None, |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("text/plain"), |state| assert!(matcher.is_match(&state).is_err())); + + let matcher = matcher.allow_no_type(); + with_state(None, |state| assert!(matcher.is_match(&state).is_ok())); + } + + #[test] + fn simple_type() + { + let matcher = ContentTypeMatcher::new(vec![mime::TEXT_PLAIN]); + with_state(None, |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("text/plain"), |state| assert!(matcher.is_match(&state).is_ok())); + with_state(Some("text/plain; charset=utf-8"), |state| assert!(matcher.is_match(&state).is_ok())); + } + + #[test] + fn complex_type() + { + let matcher = ContentTypeMatcher::new(vec!["image/svg+xml; charset=utf-8".parse().unwrap()]); + with_state(Some("image/svg"), |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("image/svg+xml"), |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("image/svg+xml; charset=utf-8"), |state| assert!(matcher.is_match(&state).is_ok())); + with_state(Some("image/svg+xml; charset=utf-8; eol=lf"), |state| assert!(matcher.is_match(&state).is_ok())); + with_state(Some("image/svg+xml; charset=us-ascii"), |state| assert!(matcher.is_match(&state).is_err())); + with_state(Some("image/svg+json; charset=utf-8"), |state| assert!(matcher.is_match(&state).is_err())); + } + + #[test] + fn type_mismatch() + { + let matcher = ContentTypeMatcher::new(vec![mime::TEXT_HTML]); + with_state(Some("text/plain"), |state| assert!(matcher.is_match(&state).is_err())); + } +} diff --git a/gotham_restful/src/matcher/mod.rs b/gotham_restful/src/matcher/mod.rs new file mode 100644 index 0000000..e1029e3 --- /dev/null +++ b/gotham_restful/src/matcher/mod.rs @@ -0,0 +1,37 @@ +use itertools::Itertools; +use mime::Mime; +use std::collections::HashMap; + +mod accept; +pub use accept::AcceptHeaderMatcher; + +mod content_type; +pub use content_type::ContentTypeMatcher; + +type LookupTable = HashMap>; + +trait LookupTableFromTypes +{ + fn from_types<'a, I : Iterator>(types : I, include_stars : bool) -> Self; +} + +impl LookupTableFromTypes for LookupTable +{ + fn from_types<'a, I : Iterator>(types : I, include_stars : bool) -> Self + { + if include_stars + { + types + .enumerate() + .flat_map(|(i, mime)| vec![("*/*".to_owned(), i), (format!("{}/*", mime.type_()), i), (mime.essence_str().to_owned(), i)].into_iter()) + .into_group_map() + } + else + { + types + .enumerate() + .map(|(i, mime)| (mime.essence_str().to_owned(), i)) + .into_group_map() + } + } +} diff --git a/gotham_restful/src/result.rs b/gotham_restful/src/result.rs index a8d49ed..792298f 100644 --- a/gotham_restful/src/result.rs +++ b/gotham_restful/src/result.rs @@ -6,7 +6,7 @@ use futures_util::{future, future::FutureExt}; use gotham::hyper::Body; #[cfg(feature = "errorlog")] use log::error; -use mime::{Mime, APPLICATION_JSON, STAR_STAR}; +use mime::{Mime, APPLICATION_JSON}; #[cfg(feature = "openapi")] use openapiv3::{SchemaKind, StringFormat, StringType, Type, VariantOrUnknownOrEmpty}; use serde::Serialize; @@ -581,11 +581,6 @@ where future::ok(Response::new(StatusCode::OK, self.raw, Some(self.mime.clone()))).boxed() } - fn accepted_types() -> Option> - { - Some(vec![STAR_STAR]) - } - #[cfg(feature = "openapi")] fn schema() -> OpenapiSchema { diff --git a/gotham_restful/src/routing.rs b/gotham_restful/src/routing.rs index 617e59c..7ff6ce1 100644 --- a/gotham_restful/src/routing.rs +++ b/gotham_restful/src/routing.rs @@ -1,4 +1,5 @@ use crate::{ + matcher::{AcceptHeaderMatcher, ContentTypeMatcher}, resource::*, result::{ResourceError, ResourceResult, Response}, RequestBody, @@ -15,11 +16,7 @@ use gotham::{ router::{ builder::*, non_match::RouteNonMatch, - route::matcher::{ - content_type::ContentTypeHeaderRouteMatcher, - AcceptHeaderRouteMatcher, - RouteMatcher - } + route::matcher::RouteMatcher }, state::{FromState, State} }; @@ -253,7 +250,7 @@ fn delete_handler(state : State) -> Pin + matcher : Option } impl RouteMatcher for MaybeMatchAcceptHeader @@ -276,7 +273,7 @@ impl From>> for MaybeMatchAcceptHeader types => types }; Self { - matcher: types.map(AcceptHeaderRouteMatcher::new) + matcher: types.map(AcceptHeaderMatcher::new) } } } @@ -284,7 +281,7 @@ impl From>> for MaybeMatchAcceptHeader #[derive(Clone)] struct MaybeMatchContentTypeHeader { - matcher : Option + matcher : Option } impl RouteMatcher for MaybeMatchContentTypeHeader @@ -303,7 +300,7 @@ impl From>> for MaybeMatchContentTypeHeader fn from(types : Option>) -> Self { Self { - matcher: types.map(ContentTypeHeaderRouteMatcher::new) + matcher: types.map(ContentTypeMatcher::new).map(ContentTypeMatcher::allow_no_type) } } }