1
0
Fork 0
mirror of https://gitlab.com/msrd0/gotham-restful.git synced 2025-02-22 20:52:27 +00:00

Add path matchers that are more capable than gotham's stock ones

This commit is contained in:
msrd0 2020-04-25 18:31:57 +00:00
parent d08d9bea8c
commit 4ce53bc361
7 changed files with 435 additions and 16 deletions

View file

@ -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 = []

View file

@ -151,6 +151,8 @@ pub use auth::{
StaticAuthHandler
};
pub mod matcher;
#[cfg(feature = "openapi")]
mod openapi;
#[cfg(feature = "openapi")]

View 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()));
}
}

View 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()));
}
}

View 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()
}
}
}

View file

@ -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
{

View file

@ -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)
}
}
}