Skip to main content

http_handle/
routing.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2023 - 2026 HTTP Handle
3
4//! Layer-7 routing: pick a document root by host, path prefix, method
5//! and headers.
6//!
7//! A [`Router`] holds validated [`Route`]s. Each route mounts one
8//! document root under a path prefix; a request that matches is served
9//! from that root with the prefix removed. When a server has a router,
10//! a request that matches no route gets a plain `404 Not Found`, never
11//! the server's own document root, so routing is deterministic. Add a
12//! route with the prefix `/` as an explicit fallback.
13//!
14//! Routes are tried from the highest [`Route::priority`] down. Among
15//! routes of equal priority the more specific one wins: a route with a
16//! host before one without, then the longer path prefix, then the route
17//! with more header constraints, then one with a method constraint. Two
18//! routes with the same priority and the same constraints are rejected
19//! as ambiguous by [`Router::new`].
20//!
21//! # Examples
22//!
23//! ```rust
24//! use http_handle::routing::{Route, Router};
25//! use http_handle::Server;
26//!
27//! let router = Router::new(vec![
28//!     Route::new("docs", "/docs", ".").host("docs.example.com"),
29//!     Route::new("site", "/", "."),
30//! ])
31//! .expect("valid routes");
32//!
33//! let server = Server::builder()
34//!     .address("127.0.0.1:0")
35//!     .document_root(".")
36//!     .router(router)
37//!     .build()
38//!     .expect("server");
39//! assert_eq!(server.router().map(|r| r.routes().len()), Some(2));
40//! ```
41
42use crate::request::Request;
43use crate::response::Response;
44use serde::{Deserialize, Serialize};
45use std::cmp::Reverse;
46use std::fs;
47use std::path::{Path, PathBuf};
48use thiserror::Error;
49
50/// One routing rule: request constraints and the document root it
51/// serves.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Route {
54    /// Unique name, used in errors and diagnostics.
55    pub name: String,
56    /// Host the request must name (the `Host` header without its port,
57    /// compared case-insensitively). `None` matches any host.
58    #[serde(default)]
59    pub host: Option<String>,
60    /// Path prefix, matched on segment boundaries: `/docs` matches
61    /// `/docs` and `/docs/a.html`, not `/docsx`. `/` matches every path.
62    pub path_prefix: String,
63    /// Allowed methods. Empty allows every method.
64    #[serde(default)]
65    pub methods: Vec<String>,
66    /// Headers that must be present with exactly this value. Names are
67    /// compared case-insensitively.
68    #[serde(default)]
69    pub headers: Vec<(String, String)>,
70    /// Higher values are tried first. Defaults to 0.
71    #[serde(default)]
72    pub priority: i32,
73    /// Directory served for requests that match this route.
74    pub document_root: PathBuf,
75    /// `Some(false)` serves this route uncompressed whatever the
76    /// server's [`CompressionPolicy`](crate::compression::CompressionPolicy);
77    /// `None` or `Some(true)` follows the server.
78    #[serde(default)]
79    pub compression: Option<bool>,
80}
81
82impl Route {
83    /// Creates a route that serves `document_root` under `path_prefix`.
84    pub fn new(
85        name: &str,
86        path_prefix: &str,
87        document_root: impl Into<PathBuf>,
88    ) -> Self {
89        Self {
90            name: name.to_string(),
91            host: None,
92            path_prefix: path_prefix.to_string(),
93            methods: Vec::new(),
94            headers: Vec::new(),
95            priority: 0,
96            document_root: document_root.into(),
97            compression: None,
98        }
99    }
100
101    /// Restricts the route to one host.
102    #[must_use]
103    pub fn host(mut self, host: &str) -> Self {
104        self.host = Some(host.to_string());
105        self
106    }
107
108    /// Adds an allowed method.
109    #[must_use]
110    pub fn method(mut self, method: &str) -> Self {
111        self.methods.push(method.to_string());
112        self
113    }
114
115    /// Requires a header with an exact value.
116    #[must_use]
117    pub fn header(mut self, name: &str, value: &str) -> Self {
118        self.headers.push((name.to_string(), value.to_string()));
119        self
120    }
121
122    /// Sets the priority; higher is tried first.
123    #[must_use]
124    pub fn priority(mut self, priority: i32) -> Self {
125        self.priority = priority;
126        self
127    }
128
129    /// Switches compression on or off for this route.
130    #[must_use]
131    pub fn compression(mut self, enabled: bool) -> Self {
132        self.compression = Some(enabled);
133        self
134    }
135
136    /// Whether `request` meets every constraint of this route.
137    pub fn matches(&self, request: &Request) -> bool {
138        self.host_matches(request)
139            && prefix_matches(
140                &self.path_prefix,
141                path_only(request.path()),
142            )
143            && (self.methods.is_empty()
144                || self.methods.iter().any(|m| m == request.method()))
145            && self.headers.iter().all(|(name, value)| {
146                request.header(name) == Some(value)
147            })
148    }
149
150    fn host_matches(&self, request: &Request) -> bool {
151        let Some(expected) = self.host.as_deref() else {
152            return true;
153        };
154        request
155            .header("host")
156            .map(host_without_port)
157            .is_some_and(|actual| actual.eq_ignore_ascii_case(expected))
158    }
159
160    /// Sort key: priority first, then specificity.
161    fn rank(&self) -> Reverse<(i32, bool, usize, usize, bool)> {
162        Reverse((
163            self.priority,
164            self.host.is_some(),
165            self.path_prefix.len(),
166            self.headers.len(),
167            !self.methods.is_empty(),
168        ))
169    }
170
171    /// Normalises names so matching and conflict checks are exact.
172    fn normalised(mut self) -> Self {
173        self.host = self.host.map(|h| h.to_ascii_lowercase());
174        if self.path_prefix.len() > 1 {
175            let trimmed = self.path_prefix.trim_end_matches('/');
176            self.path_prefix =
177                if trimmed.is_empty() { "/" } else { trimmed }.into();
178        }
179        for method in &mut self.methods {
180            method.make_ascii_uppercase();
181        }
182        self.methods.sort();
183        self.methods.dedup();
184        for (name, _) in &mut self.headers {
185            name.make_ascii_lowercase();
186        }
187        self.headers.sort();
188        self
189    }
190
191    /// Everything that decides whether the route matches.
192    fn constraints(&self) -> impl PartialEq + '_ {
193        (
194            self.priority,
195            &self.host,
196            &self.path_prefix,
197            &self.methods,
198            &self.headers,
199        )
200    }
201}
202
203/// Why a set of routes was rejected.
204#[derive(Clone, Debug, PartialEq, Eq, Error)]
205pub enum RouteError {
206    /// A route has an empty name.
207    #[error("route name must not be empty")]
208    EmptyName,
209    /// Two routes share a name.
210    #[error("duplicate route name `{0}`")]
211    DuplicateName(String),
212    /// A path prefix does not start with `/`.
213    #[error(
214        "route `{route}`: path prefix `{prefix}` must start with `/`"
215    )]
216    InvalidPathPrefix {
217        /// Route name.
218        route: String,
219        /// The rejected prefix.
220        prefix: String,
221    },
222    /// A method or host is empty, or a method is not an HTTP token.
223    #[error("route `{route}`: invalid {field} `{value}`")]
224    InvalidConstraint {
225        /// Route name.
226        route: String,
227        /// `host` or `method`.
228        field: &'static str,
229        /// The rejected value.
230        value: String,
231    },
232    /// The document root is not an existing directory.
233    #[error(
234        "route `{route}`: document root `{path}` is not a directory"
235    )]
236    InvalidDocumentRoot {
237        /// Route name.
238        route: String,
239        /// The rejected path.
240        path: String,
241    },
242    /// Two routes have the same priority and constraints, so neither
243    /// can be preferred.
244    #[error("routes `{first}` and `{second}` are ambiguous")]
245    Ambiguous {
246        /// First route name.
247        first: String,
248        /// Second route name.
249        second: String,
250    },
251}
252
253/// A validated, ordered set of routes.
254///
255/// Serialises as its list of routes; deserialising validates them
256/// again, so a stored configuration cannot bypass [`Router::new`].
257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(try_from = "Vec<Route>", into = "Vec<Route>")]
259pub struct Router {
260    routes: Vec<Route>,
261    canonical_roots: Vec<PathBuf>,
262}
263
264/// Where a routed request is served from.
265#[derive(Debug)]
266pub(crate) struct RouteTarget<'a> {
267    /// The request with the route prefix removed from its path.
268    pub(crate) request: Request,
269    /// The route's document root.
270    pub(crate) document_root: &'a Path,
271    /// The route's canonicalised document root.
272    pub(crate) canonical_root: &'a Path,
273    /// The route's compression override.
274    pub(crate) compression: Option<bool>,
275}
276
277impl Router {
278    /// Validates `routes` and orders them for matching.
279    ///
280    /// # Errors
281    ///
282    /// Returns a [`RouteError`] for an empty or duplicate name, a prefix
283    /// that does not start with `/`, an empty host or invalid method, a
284    /// document root that is not a directory, or two routes with equal
285    /// priority and constraints.
286    pub fn new(routes: Vec<Route>) -> Result<Self, RouteError> {
287        let mut routes: Vec<Route> =
288            routes.into_iter().map(Route::normalised).collect();
289        for (index, route) in routes.iter().enumerate() {
290            validate_route(route)?;
291            if let Some(other) = routes[..index]
292                .iter()
293                .find(|other| other.name == route.name)
294            {
295                return Err(RouteError::DuplicateName(
296                    other.name.clone(),
297                ));
298            }
299            if let Some(other) = routes[..index].iter().find(|other| {
300                other.constraints() == route.constraints()
301            }) {
302                return Err(RouteError::Ambiguous {
303                    first: other.name.clone(),
304                    second: route.name.clone(),
305                });
306            }
307        }
308        // Stable sort: declaration order breaks the remaining ties.
309        routes.sort_by_key(Route::rank);
310        let canonical_roots = routes
311            .iter()
312            .map(canonical_root)
313            .collect::<Result<_, _>>()?;
314        Ok(Self {
315            routes,
316            canonical_roots,
317        })
318    }
319
320    /// The routes in matching order.
321    pub fn routes(&self) -> &[Route] {
322        &self.routes
323    }
324
325    /// The first route, in matching order, that `request` meets.
326    pub fn select(&self, request: &Request) -> Option<&Route> {
327        self.routes.iter().find(|route| route.matches(request))
328    }
329
330    /// Resolves `request` to the root that serves it.
331    pub(crate) fn resolve(
332        &self,
333        request: &Request,
334    ) -> Option<RouteTarget<'_>> {
335        let index = self
336            .routes
337            .iter()
338            .position(|route| route.matches(request))?;
339        let route = &self.routes[index];
340        let mut routed = request.clone();
341        routed.path = strip_prefix(&route.path_prefix, request.path());
342        Some(RouteTarget {
343            request: routed,
344            document_root: &route.document_root,
345            canonical_root: &self.canonical_roots[index],
346            compression: route.compression,
347        })
348    }
349}
350
351impl TryFrom<Vec<Route>> for Router {
352    type Error = RouteError;
353
354    fn try_from(routes: Vec<Route>) -> Result<Self, Self::Error> {
355        Self::new(routes)
356    }
357}
358
359impl From<Router> for Vec<Route> {
360    fn from(router: Router) -> Self {
361        router.routes
362    }
363}
364
365/// The response for a request that matches no route.
366pub(crate) fn no_route_response() -> Response {
367    let mut response =
368        Response::new(404, "NOT FOUND", b"404 Not Found".to_vec());
369    response.add_header("Content-Type", "text/plain");
370    response
371}
372
373fn validate_route(route: &Route) -> Result<(), RouteError> {
374    if route.name.is_empty() {
375        return Err(RouteError::EmptyName);
376    }
377    if !route.path_prefix.starts_with('/') {
378        return Err(RouteError::InvalidPathPrefix {
379            route: route.name.clone(),
380            prefix: route.path_prefix.clone(),
381        });
382    }
383    let invalid = |field, value: &str| RouteError::InvalidConstraint {
384        route: route.name.clone(),
385        field,
386        value: value.to_string(),
387    };
388    if let Some(host) = route.host.as_deref().filter(|h| h.is_empty()) {
389        return Err(invalid("host", host));
390    }
391    if let Some(method) = route.methods.iter().find(|m| !is_token(m)) {
392        return Err(invalid("method", method));
393    }
394    Ok(())
395}
396
397fn canonical_root(route: &Route) -> Result<PathBuf, RouteError> {
398    fs::canonicalize(&route.document_root)
399        .ok()
400        .filter(|path| path.is_dir())
401        .ok_or_else(|| RouteError::InvalidDocumentRoot {
402            route: route.name.clone(),
403            path: route.document_root.display().to_string(),
404        })
405}
406
407fn is_token(value: &str) -> bool {
408    !value.is_empty() && value.bytes().all(|b| b.is_ascii_uppercase())
409}
410
411fn path_only(path: &str) -> &str {
412    path.split_once('?').map_or(path, |(path, _)| path)
413}
414
415fn prefix_matches(prefix: &str, path: &str) -> bool {
416    prefix == "/"
417        || path.strip_prefix(prefix).is_some_and(|rest| {
418            rest.is_empty() || rest.starts_with('/')
419        })
420}
421
422/// Removes the route prefix, keeping a leading `/`.
423fn strip_prefix(prefix: &str, path: &str) -> String {
424    if prefix == "/" {
425        return path.to_string();
426    }
427    let rest = path.strip_prefix(prefix).unwrap_or(path);
428    if rest.starts_with('/') {
429        rest.to_string()
430    } else {
431        format!("/{rest}")
432    }
433}
434
435/// `example.com:8080` to `example.com`, `[::1]:8080` to `[::1]`.
436fn host_without_port(host: &str) -> &str {
437    let host = host.trim();
438    if host.starts_with('[') {
439        return host.split_inclusive(']').next().unwrap_or(host);
440    }
441    match host.rsplit_once(':') {
442        Some((name, port))
443            if !port.is_empty()
444                && port.bytes().all(|b| b.is_ascii_digit()) =>
445        {
446            name
447        }
448        _ => host,
449    }
450}