Expose 'Route', 'Catcher' collision and matching.

This commit exposes four new methods:

  * `Route::collides_with(&Route)`
  * `Route::matches(&Request)`
  * `Catcher::collides_with(&Catcher)`
  * `Catcher::matches(Status, &Request)`

Each method checks the corresponding condition: whether two routes
collide, whether a route matches a request, whether two catchers
collide, and whether a catcher matches an error arising from a request.

This functionality is used internally by Rocket to make routing
decisions. By exposing these methods, external libraries can use
guaranteed consistent logic to check the same routing conditions.

Resolves #1561.
This commit is contained in:
Sergio Benitez 2023-04-10 15:16:39 -07:00
parent 0c80f7d9e0
commit b61ac6eb18
5 changed files with 299 additions and 100 deletions

View File

@ -32,16 +32,21 @@ use yansi::Paint;
///
/// # Routing
///
/// An error arising from a particular request _matches_ a catcher _iff_:
/// If a route fails by returning a failure [`Outcome`], Rocket routes the
/// erroring request to the highest precedence catcher among all the catchers
/// that [match](Catcher::matches()). See [`Catcher::matches()`] for details on
/// matching. Precedence is determined by the catcher's _base_, which is
/// provided as the first argument to [`Rocket::register()`]. Catchers with more
/// non-empty segments have a higher precedence.
///
/// * It is a default catcher _or_ has a status code matching the error code.
/// * Its base is a prefix of the normalized/decoded request URI path.
/// Rocket provides [built-in defaults](#built-in-default), but _default_
/// catchers can also be registered. A _default_ catcher is a catcher with no
/// explicit status code: `None`.
///
/// A _default_ catcher is a catcher with no explicit status code: `None`. The
/// catcher's _base_ is provided as the first argument to
/// [`Rocket::register()`](crate::Rocket::register()).
/// [`Outcome`]: crate::request::Outcome
/// [`Rocket::register()`]: crate::Rocket::register()
///
/// # Collisions
/// ## Collisions
///
/// Two catchers are said to _collide_ if there exists an error that matches
/// both catchers. Colliding catchers present a routing ambiguity and are thus
@ -50,7 +55,7 @@ use yansi::Paint;
/// after it becomes statically impossible to register any more catchers on an
/// instance of `Rocket`.
///
/// ### Built-In Default
/// ## Built-In Default
///
/// Rocket's provides a built-in default catcher that can handle all errors. It
/// produces HTML or JSON, depending on the value of the `Accept` header. As
@ -119,14 +124,8 @@ pub struct Catcher {
/// The catcher's calculated rank.
///
/// This is [base.segments().len() | base.chars().len()].
pub(crate) rank: u64,
}
fn compute_rank(base: &uri::Origin<'_>) -> u64 {
let major = u32::MAX - base.path().segments().num() as u32;
let minor = u32::MAX - base.path().as_str().chars().count() as u32;
((major as u64) << 32) | (minor as u64)
/// This is -(number of nonempty segments in base).
pub(crate) rank: isize,
}
impl Catcher {
@ -178,7 +177,7 @@ impl Catcher {
name: None,
base: uri::Origin::ROOT,
handler: Box::new(handler),
rank: compute_rank(&uri::Origin::ROOT),
rank: 0,
code
}
}
@ -250,7 +249,7 @@ impl Catcher {
let new_base = uri::Origin::parse_owned(mapper(self.base))?;
self.base = new_base.into_normalized_nontrailing();
self.base.clear_query();
self.rank = compute_rank(&self.base);
self.rank = -1 * (self.base().segments().filter(|s| !s.is_empty()).count() as isize);
Ok(self)
}
}

View File

@ -118,7 +118,8 @@ impl<F: Clone + Sync + Send + 'static> Handler for F
}
}
#[cfg(test)]
// Used in tests! Do not use, please.
#[doc(hidden)]
pub fn dummy_handler<'r>(_: Status, _: &'r Request<'_>) -> BoxFuture<'r> {
Box::pin(async move { Ok(Response::new()) })
}

View File

@ -38,33 +38,22 @@ use crate::sentinel::Sentry;
///
/// # Routing
///
/// A request _matches_ a route _iff_:
/// A request is _routed_ to a route if it has the highest precedence (lowest
/// rank) among all routes that [match](Route::matches()) the request. See
/// [`Route::matches()`] for details on what it means for a request to match.
///
/// * The route's method matches that of the incoming request.
/// * The route's format (if any) matches that of the incoming request.
/// - If route specifies a format, it only matches requests for that format.
/// - If route doesn't specify a format, it matches requests for any format.
/// - A route's `format` matches against the `Accept` header in the request
/// when the route's method [`supports_payload()`] and `Content-Type`
/// header otherwise.
/// - Non-specific `Accept` header components (`*`) match anything.
/// * All static components in the route's path match the corresponding
/// components in the same position in the incoming request.
/// * All static components in the route's query string are also in the
/// request query string, though in any position. If there is no query
/// in the route, requests with and without queries match.
/// Note that a single request _may_ be routed to multiple routes if a route
/// forwards. If a route fails, the request is instead routed to the highest
/// precedence [`Catcher`](crate::Catcher).
///
/// Rocket routes requests to matching routes.
/// ## Collisions
///
/// [`supports_payload()`]: Method::supports_payload()
///
/// # Collisions
///
/// Two routes are said to _collide_ if there exists a request that matches both
/// routes. Colliding routes present a routing ambiguity and are thus disallowed
/// by Rocket. Because routes can be constructed dynamically, collision checking
/// is done at [`ignite`](crate::Rocket::ignite()) time, after it becomes
/// statically impossible to add any more routes to an instance of `Rocket`.
/// Two routes are said to [collide](Route::collides_with()) if there exists a
/// request that matches both routes. Colliding routes present a routing
/// ambiguity and are thus disallowed by Rocket. Because routes can be
/// constructed dynamically, collision checking is done at
/// [`ignite`](crate::Rocket::ignite()) time, after it becomes statically
/// impossible to add any more routes to an instance of `Rocket`.
///
/// Note that because query parsing is always lenient -- extra and missing query
/// parameters are allowed -- queries do not directly impact whether two routes
@ -300,12 +289,6 @@ impl Route {
self.uri = RouteUri::try_new(&base, &self.uri.unmounted_origin.to_string())?;
Ok(self)
}
/// Returns `true` if `self` collides with `other`.
#[doc(hidden)]
pub fn collides_with(&self, other: &Route) -> bool {
crate::router::Collide::collides_with(self, other)
}
}
impl fmt::Display for Route {

View File

@ -7,22 +7,86 @@ pub trait Collide<T = Self> {
fn collides_with(&self, other: &T) -> bool;
}
impl Collide for Route {
/// Determines if two routes can match against some request. That is, if two
/// routes `collide`, there exists a request that can match against both
/// routes.
impl Route {
/// Returns `true` if `self` collides with `other`.
///
/// This implementation is used at initialization to check if two user
/// routes collide before launching. Format collisions works like this:
/// A [_collision_](Route#collisions) between two routes occurs when there
/// exists a request that could [match](Route::matches()) either route. That
/// is, a routing ambiguity would ensue if both routes were made available
/// to the router.
///
/// * If route specifies a format, it only gets requests for that format.
/// * If route doesn't specify a format, it gets requests for any format.
/// Specifically, a collision occurs when two routes `a` and `b`:
///
/// Because query parsing is lenient, and dynamic query parameters can be
/// missing, the particularities of a query string do not impact whether two
/// routes collide. The query effects the route's color, however, which
/// effects its rank.
fn collides_with(&self, other: &Route) -> bool {
/// * Have the same [method](Route::method).
/// * Have the same [rank](Route#default-ranking).
/// * The routes' methods don't support a payload _or_ the routes'
/// methods support a payload and the formats overlap. Formats overlap
/// when:
/// - The top-level type of either is `*` or the top-level types are
/// equivalent.
/// - The sub-level type of either is `*` or the sub-level types are
/// equivalent.
/// * Have overlapping route URIs. This means that either:
/// - The URIs have the same number of segments `n`, and for `i` in
/// `0..n`, either `a.uri[i]` is dynamic _or_ `b.uri[i]` is dynamic
/// _or_ they're both static with the same value.
/// - One URI has fewer segments _and_ ends with a trailing dynamic
/// parameter _and_ the preceeding segments in both routes match the
/// conditions above.
///
/// Collisions are symmetric: for any routes `a` and `b`,
/// `a.collides_with(b) => b.collides_with(a)`.
///
/// # Example
///
/// ```rust
/// use rocket::Route;
/// use rocket::http::{Method, MediaType};
/// # use rocket::route::dummy_handler as handler;
///
/// // Two routes with the same method, rank, URI, and formats collide.
/// let a = Route::new(Method::Get, "/", handler);
/// let b = Route::new(Method::Get, "/", handler);
/// assert!(a.collides_with(&b));
///
/// // Two routes with the same method, rank, URI, and overlapping formats.
/// let mut a = Route::new(Method::Post, "/", handler);
/// a.format = Some(MediaType::new("*", "custom"));
/// let mut b = Route::new(Method::Post, "/", handler);
/// b.format = Some(MediaType::new("text", "*"));
/// assert!(a.collides_with(&b));
///
/// // Two routes with different ranks don't collide.
/// let a = Route::ranked(1, Method::Get, "/", handler);
/// let b = Route::ranked(2, Method::Get, "/", handler);
/// assert!(!a.collides_with(&b));
///
/// // Two routes with different methods don't collide.
/// let a = Route::new(Method::Put, "/", handler);
/// let b = Route::new(Method::Post, "/", handler);
/// assert!(!a.collides_with(&b));
///
/// // Two routes with non-overlapping URIs do not collide.
/// let a = Route::new(Method::Get, "/foo", handler);
/// let b = Route::new(Method::Get, "/bar/<baz>", handler);
/// assert!(!a.collides_with(&b));
///
/// // Two payload-supporting routes with non-overlapping formats.
/// let mut a = Route::new(Method::Post, "/", handler);
/// a.format = Some(MediaType::HTML);
/// let mut b = Route::new(Method::Post, "/", handler);
/// b.format = Some(MediaType::JSON);
/// assert!(!a.collides_with(&b));
///
/// // Two non payload-supporting routes with non-overlapping formats
/// // collide. A request with `Accept: */*` matches both.
/// let mut a = Route::new(Method::Get, "/", handler);
/// a.format = Some(MediaType::HTML);
/// let mut b = Route::new(Method::Get, "/", handler);
/// b.format = Some(MediaType::JSON);
/// assert!(a.collides_with(&b));
/// ```
pub fn collides_with(&self, other: &Route) -> bool {
self.method == other.method
&& self.rank == other.rank
&& self.uri.collides_with(&other.uri)
@ -30,16 +94,68 @@ impl Collide for Route {
}
}
impl Collide for Catcher {
/// Determines if two catchers are in conflict: there exists a request for
/// which there exist no rule to determine _which_ of the two catchers to
/// use. This means that the catchers:
impl Catcher {
/// Returns `true` if `self` collides with `other`.
///
/// * Have the same base.
/// * Have the same status code or are both defaults.
/// A [_collision_](Catcher#collisions) between two catchers occurs when
/// there exists a request and ensuing error that could
/// [match](Catcher::matches()) both catchers. That is, a routing ambiguity
/// would ensue if both catchers were made available to the router.
///
/// Specifically, a collision occurs when two catchers:
///
/// * Have the same [base](Catcher::base()).
/// * Have the same status [code](Catcher::code) or are both `default`.
///
/// Collisions are symmetric: for any catchers `a` and `b`,
/// `a.collides_with(b) => b.collides_with(a)`.
///
/// # Example
///
/// ```rust
/// use rocket::Catcher;
/// # use rocket::catcher::dummy_handler as handler;
///
/// // Two catchers with the same status code and base collide.
/// let a = Catcher::new(404, handler).map_base(|_| format!("/foo")).unwrap();
/// let b = Catcher::new(404, handler).map_base(|_| format!("/foo")).unwrap();
/// assert!(a.collides_with(&b));
///
/// // Two catchers with a different base _do not_ collide.
/// let a = Catcher::new(404, handler);
/// let b = a.clone().map_base(|_| format!("/bar")).unwrap();
/// assert_eq!(a.base(), "/");
/// assert_eq!(b.base(), "/bar");
/// assert!(!a.collides_with(&b));
///
/// // Two catchers with a different codes _do not_ collide.
/// let a = Catcher::new(404, handler);
/// let b = Catcher::new(500, handler);
/// assert_eq!(a.base(), "/");
/// assert_eq!(b.base(), "/");
/// assert!(!a.collides_with(&b));
///
/// // A catcher _with_ a status code and one _without_ do not collide.
/// let a = Catcher::new(404, handler);
/// let b = Catcher::new(None, handler);
/// assert!(!a.collides_with(&b));
/// ```
pub fn collides_with(&self, other: &Self) -> bool {
self.code == other.code && self.base().segments().eq(other.base().segments())
}
}
impl Collide for Route {
#[inline(always)]
fn collides_with(&self, other: &Route) -> bool {
Route::collides_with(&self, other)
}
}
impl Collide for Catcher {
#[inline(always)]
fn collides_with(&self, other: &Self) -> bool {
self.code == other.code
&& self.base.path().segments().eq(other.base.path().segments())
Catcher::collides_with(&self, other)
}
}
@ -75,17 +191,17 @@ impl Collide for MediaType {
}
fn formats_collide(route: &Route, other: &Route) -> bool {
// When matching against the `Accept` header, the client can always provide
// a media type that will cause a collision through non-specificity, i.e,
// `*/*` matches everything.
if !route.method.supports_payload() {
// If the routes' method doesn't support a payload, then format matching
// considers the `Accept` header. The client can always provide a media type
// that will cause a collision through non-specificity, i.e, `*/*`.
if !route.method.supports_payload() && !other.method.supports_payload() {
return true;
}
// When matching against the `Content-Type` header, we'll only consider
// requests as having a `Content-Type` if they're fully specified. If a
// route doesn't have a `format`, it accepts all `Content-Type`s. If a
// request doesn't have a format, it only matches routes without a format.
// Payload supporting methods match against `Content-Type`. We only
// consider requests as having a `Content-Type` if they're fully
// specified. A route without a `format` accepts all `Content-Type`s. A
// request without a format only matches routes without a format.
match (route.format.as_ref(), other.format.as_ref()) {
(Some(a), Some(b)) => a.collides_with(b),
_ => true

View File

@ -4,37 +4,137 @@ use crate::http::Status;
use crate::route::Color;
impl Route {
/// Determines if this route matches against the given request.
/// Returns `true` if `self` matches `request`.
///
/// This means that:
/// A [_match_](Route#routing) occurs when:
///
/// * The route's method matches that of the incoming request.
/// * The route's format (if any) matches that of the incoming request.
/// - If route specifies format, it only gets requests for that format.
/// - If route doesn't specify format, it gets requests for any format.
/// * All static components in the route's path match the corresponding
/// components in the same position in the incoming request.
/// * All static components in the route's query string are also in the
/// request query string, though in any position. If there is no query
/// in the route, requests with/without queries match.
#[doc(hidden)]
pub fn matches(&self, req: &Request<'_>) -> bool {
self.method == req.method()
&& paths_match(self, req)
&& queries_match(self, req)
&& formats_match(self, req)
/// * Either the route has no format _or_:
/// - If the route's method supports a payload, the request's
/// `Content-Type` is [fully specified] and [collides with] the
/// route's format.
/// - If the route's method does not support a payload, the request
/// either has no `Accept` header or it [collides with] with the
/// route's format.
/// * All static segments in the route's URI match the corresponding
/// components in the same position in the incoming request URI.
/// * The route URI has no query part _or_ all static segments in the
/// route's query string are in the request query string, though in any
/// position.
///
/// [fully specified]: crate::http::MediaType::specificity()
/// [collides with]: Route::collides_with()
///
/// For a request to be routed to a particular route, that route must both
/// `match` _and_ have the highest precedence among all matching routes for
/// that request. In other words, a `match` is a necessary but insufficient
/// condition to determine if a route will handle a particular request.
///
/// The precedence of a route is determined by its rank. Routes with lower
/// ranks have higher precedence. [By default](Route#default-ranking), more
/// specific routes are assigned a lower ranking.
///
/// # Example
///
/// ```rust
/// use rocket::Route;
/// use rocket::http::Method;
/// # use rocket::local::blocking::Client;
/// # use rocket::route::dummy_handler as handler;
///
/// // This route handles GET requests to `/<hello>`.
/// let a = Route::new(Method::Get, "/<hello>", handler);
///
/// // This route handles GET requests to `/здрасти`.
/// let b = Route::new(Method::Get, "/здрасти", handler);
///
/// # let client = Client::debug(rocket::build()).unwrap();
/// // Let's say `request` is `GET /hello`. The request matches only `a`:
/// let request = client.get("/hello");
/// # let request = request.inner();
/// assert!(a.matches(&request));
/// assert!(!b.matches(&request));
///
/// // Now `request` is `GET /здрасти`. It matches both `a` and `b`:
/// let request = client.get("/здрасти");
/// # let request = request.inner();
/// assert!(a.matches(&request));
/// assert!(b.matches(&request));
///
/// // But `b` is more specific, so it has lower rank (higher precedence)
/// // by default, so Rocket would route the request to `b`, not `a`.
/// assert!(b.rank < a.rank);
/// ```
pub fn matches(&self, request: &Request<'_>) -> bool {
self.method == request.method()
&& paths_match(self, request)
&& queries_match(self, request)
&& formats_match(self, request)
}
}
impl Catcher {
/// Determines if this catcher is responsible for handling the error with
/// `status` that occurred during request `req`. A catcher matches if:
/// Returns `true` if `self` matches errors with `status` that occured
/// during `request`.
///
/// * It is a default catcher _or_ has a code of `status`.
/// * Its base is a prefix of the normalized/decoded `req.path()`.
pub(crate) fn matches(&self, status: Status, req: &Request<'_>) -> bool {
/// A [_match_](Catcher#routing) between a `Catcher` and a (`Status`,
/// `&Request`) pair occurs when:
///
/// * The catcher has the same [code](Catcher::code) as
/// [`status`](Status::code) _or_ is `default`.
/// * The catcher's [base](Catcher::base()) is a prefix of the `request`'s
/// [normalized](crate::http::uri::Origin#normalization) URI.
///
/// For an error arising from a request to be routed to a particular
/// catcher, that catcher must both `match` _and_ have higher precedence
/// than any other catcher that matches. In other words, a `match` is a
/// necessary but insufficient condition to determine if a catcher will
/// handle a particular error.
///
/// The precedence of a catcher is determined by:
///
/// 1. The number of _complete_ segments in the catcher's `base`.
/// 2. Whether the catcher is `default` or not.
///
/// Non-default routes, and routes with more complete segments in their
/// base, have higher precedence.
///
/// # Example
///
/// ```rust
/// use rocket::Catcher;
/// use rocket::http::Status;
/// # use rocket::local::blocking::Client;
/// # use rocket::catcher::dummy_handler as handler;
///
/// // This catcher handles 404 errors with a base of `/`.
/// let a = Catcher::new(404, handler);
///
/// // This catcher handles 404 errors with a base of `/bar`.
/// let b = a.clone().map_base(|_| format!("/bar")).unwrap();
///
/// # let client = Client::debug(rocket::build()).unwrap();
/// // Let's say `request` is `GET /` that 404s. The error matches only `a`:
/// let request = client.get("/");
/// # let request = request.inner();
/// assert!(a.matches(Status::NotFound, &request));
/// assert!(!b.matches(Status::NotFound, &request));
///
/// // Now `request` is a 404 `GET /bar`. The error matches `a` and `b`:
/// let request = client.get("/bar");
/// # let request = request.inner();
/// assert!(a.matches(Status::NotFound, &request));
/// assert!(b.matches(Status::NotFound, &request));
///
/// // Note that because `b`'s base' has more complete segments that `a's,
/// // Rocket would route the error to `b`, not `a`, even though both match.
/// let a_count = a.base().segments().filter(|s| !s.is_empty()).count();
/// let b_count = b.base().segments().filter(|s| !s.is_empty()).count();
/// assert!(b_count > a_count);
/// ```
pub fn matches(&self, status: Status, request: &Request<'_>) -> bool {
self.code.map_or(true, |code| code == status.code)
&& self.base.path().segments().prefix_of(req.uri().path().segments())
&& self.base().segments().prefix_of(request.uri().path().segments())
}
}