mirror of
https://gitlab.com/msrd0/gotham-restful.git
synced 2025-02-22 20:52:27 +00:00
Merge branch 'path-matchers' into 'master'
Add path matchers that are more capable than gotham's stock ones See merge request msrd0/gotham-restful!11
This commit is contained in:
commit
805df80971
7 changed files with 435 additions and 16 deletions
|
@ -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 = []
|
||||
|
|
|
@ -151,6 +151,8 @@ pub use auth::{
|
|||
StaticAuthHandler
|
||||
};
|
||||
|
||||
pub mod matcher;
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
mod openapi;
|
||||
#[cfg(feature = "openapi")]
|
||||
|
|
214
gotham_restful/src/matcher/accept.rs
Normal file
214
gotham_restful/src/matcher/accept.rs
Normal file
|
@ -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<f32>
|
||||
}
|
||||
|
||||
impl QMime
|
||||
{
|
||||
fn new(mime : Mime, weight : Option<f32>) -> 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<Self, Self::Err>
|
||||
{
|
||||
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<Mime>,
|
||||
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<Mime>) -> 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::<Result<Vec<QMime>, _>>()
|
||||
.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<F>(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()));
|
||||
}
|
||||
}
|
173
gotham_restful/src/matcher/content_type.rs
Normal file
173
gotham_restful/src/matcher/content_type.rs
Normal file
|
@ -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<Mime>,
|
||||
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<Mime>) -> 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<F>(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()));
|
||||
}
|
||||
}
|
37
gotham_restful/src/matcher/mod.rs
Normal file
37
gotham_restful/src/matcher/mod.rs
Normal file
|
@ -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<String, Vec<usize>>;
|
||||
|
||||
trait LookupTableFromTypes
|
||||
{
|
||||
fn from_types<'a, I : Iterator<Item = &'a Mime>>(types : I, include_stars : bool) -> Self;
|
||||
}
|
||||
|
||||
impl LookupTableFromTypes for LookupTable
|
||||
{
|
||||
fn from_types<'a, I : Iterator<Item = &'a Mime>>(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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Vec<Mime>>
|
||||
{
|
||||
Some(vec![STAR_STAR])
|
||||
}
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
fn schema() -> OpenapiSchema
|
||||
{
|
||||
|
|
|
@ -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<Handler : ResourceDelete>(state : State) -> Pin<Box<HandlerFut
|
|||
#[derive(Clone)]
|
||||
struct MaybeMatchAcceptHeader
|
||||
{
|
||||
matcher : Option<AcceptHeaderRouteMatcher>
|
||||
matcher : Option<AcceptHeaderMatcher>
|
||||
}
|
||||
|
||||
impl RouteMatcher for MaybeMatchAcceptHeader
|
||||
|
@ -276,7 +273,7 @@ impl From<Option<Vec<Mime>>> for MaybeMatchAcceptHeader
|
|||
types => types
|
||||
};
|
||||
Self {
|
||||
matcher: types.map(AcceptHeaderRouteMatcher::new)
|
||||
matcher: types.map(AcceptHeaderMatcher::new)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -284,7 +281,7 @@ impl From<Option<Vec<Mime>>> for MaybeMatchAcceptHeader
|
|||
#[derive(Clone)]
|
||||
struct MaybeMatchContentTypeHeader
|
||||
{
|
||||
matcher : Option<ContentTypeHeaderRouteMatcher>
|
||||
matcher : Option<ContentTypeMatcher>
|
||||
}
|
||||
|
||||
impl RouteMatcher for MaybeMatchContentTypeHeader
|
||||
|
@ -303,7 +300,7 @@ impl From<Option<Vec<Mime>>> for MaybeMatchContentTypeHeader
|
|||
fn from(types : Option<Vec<Mime>>) -> Self
|
||||
{
|
||||
Self {
|
||||
matcher: types.map(ContentTypeHeaderRouteMatcher::new)
|
||||
matcher: types.map(ContentTypeMatcher::new).map(ContentTypeMatcher::allow_no_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue