Skip to main content

rainier_routing/
route.rs

1//! A single [`Route`]: its URI pattern, methods, middleware, name and
2//! parameter constraints.
3
4use std::any::TypeId;
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use percent_encoding::percent_decode_str;
9use rainier_http::Method;
10use rainier_middleware::{IntoMiddlewareStack, Middleware, MiddlewareStack};
11
12use crate::handler::RouteHandler;
13
14/// One compiled piece of a URI pattern.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Segment {
17    /// A literal, matched exactly.
18    Static(String),
19    /// `{name}` — one segment, captured.
20    Param(String),
21    /// `{name?}` — one optional segment, captured. Only meaningful trailing.
22    OptionalParam(String),
23    /// `{name*}` — the rest of the path, captured with its slashes.
24    Wildcard(String),
25}
26
27/// A restriction on what a route parameter may contain — a closed
28/// set of named kinds rather than a regular expression.
29///
30/// Named kinds instead of regexes keeps the crate free of a regex dependency
31/// and, more usefully, makes the common constraints declarative and
32/// impossible to get subtly wrong. [`ParamConstraint::Custom`] covers the rest.
33#[derive(Clone)]
34pub enum ParamConstraint {
35    /// ASCII digits only.
36    Number,
37    /// ASCII letters only.
38    Alpha,
39    /// ASCII letters and digits.
40    AlphaNumeric,
41    /// Letters, digits, `-` and `_` — a URL slug.
42    Slug,
43    /// A canonical 8-4-4-4-12 hexadecimal UUID.
44    Uuid,
45    /// One of a fixed set of values.
46    In(Vec<String>),
47    /// Any predicate.
48    Custom(Arc<dyn Fn(&str) -> bool + Send + Sync>),
49}
50
51impl ParamConstraint {
52    /// Whether `value` satisfies this constraint.
53    pub fn allows(&self, value: &str) -> bool {
54        match self {
55            ParamConstraint::Number => {
56                !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
57            }
58            ParamConstraint::Alpha => {
59                !value.is_empty() && value.bytes().all(|b| b.is_ascii_alphabetic())
60            }
61            ParamConstraint::AlphaNumeric => {
62                !value.is_empty() && value.bytes().all(|b| b.is_ascii_alphanumeric())
63            }
64            ParamConstraint::Slug => {
65                !value.is_empty()
66                    && value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
67            }
68            ParamConstraint::Uuid => is_uuid(value),
69            ParamConstraint::In(allowed) => allowed.iter().any(|a| a == value),
70            ParamConstraint::Custom(predicate) => predicate(value),
71        }
72    }
73}
74
75impl std::fmt::Debug for ParamConstraint {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            ParamConstraint::Number => f.write_str("Number"),
79            ParamConstraint::Alpha => f.write_str("Alpha"),
80            ParamConstraint::AlphaNumeric => f.write_str("AlphaNumeric"),
81            ParamConstraint::Slug => f.write_str("Slug"),
82            ParamConstraint::Uuid => f.write_str("Uuid"),
83            ParamConstraint::In(values) => write!(f, "In({values:?})"),
84            ParamConstraint::Custom(_) => f.write_str("Custom(..)"),
85        }
86    }
87}
88
89fn is_uuid(value: &str) -> bool {
90    let groups = [8, 4, 4, 4, 12];
91    let mut parts = value.split('-');
92    for expected in groups {
93        let Some(part) = parts.next() else { return false };
94        if part.len() != expected || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
95            return false;
96        }
97    }
98    parts.next().is_none()
99}
100
101/// A declared route.
102#[derive(Clone)]
103pub struct Route {
104    methods: Vec<Method>,
105    uri: String,
106    segments: Vec<Segment>,
107    handler: Arc<dyn RouteHandler>,
108    middleware: MiddlewareStack,
109    excluded_middleware: Vec<TypeId>,
110    name: Option<String>,
111    constraints: HashMap<String, ParamConstraint>,
112}
113
114impl Route {
115    /// A route for `methods` at `uri`, served by `handler`.
116    ///
117    /// `HEAD` is added alongside `GET` automatically, as every HTTP server is
118    /// expected to answer a `HEAD` wherever it answers a `GET`.
119    pub fn new(
120        methods: Vec<Method>,
121        uri: impl Into<String>,
122        handler: Arc<dyn RouteHandler>,
123    ) -> Self {
124        let uri = normalise_uri(&uri.into());
125        let segments = compile(&uri);
126
127        let mut methods = methods;
128        if methods.contains(&Method::GET) && !methods.contains(&Method::HEAD) {
129            methods.push(Method::HEAD);
130        }
131
132        Self {
133            methods,
134            uri,
135            segments,
136            handler,
137            middleware: MiddlewareStack::new(),
138            excluded_middleware: Vec::new(),
139            name: None,
140            constraints: HashMap::new(),
141        }
142    }
143
144    // --- declaration -------------------------------------------------------
145
146    // Builders take `&mut self` and return `&mut Self` rather than consuming
147    // `self`, because a route is declared *into* the router: `router.get(..)`
148    // hands back a borrow of the route it just stored, and the chained calls
149    // configure it in place.
150
151    /// Name the route, so it can be resolved into a URL.
152    pub fn name(&mut self, name: impl Into<String>) -> &mut Self {
153        self.name = Some(name.into());
154        self
155    }
156
157    /// Attach middleware.
158    ///
159    /// Takes the middleware **itself** — one instance, a tuple of them, or a
160    /// whole [`MiddlewareStack`] from a group function. There is no name to
161    /// misspell and no registry to look anything up in.
162    ///
163    /// ```ignore
164    /// router.post("/login", login)
165    ///     .middleware(ThrottleRequests::per_minute(10));
166    ///
167    /// router.get("/admin", dashboard)
168    ///     .middleware((Authenticate::new(auth), RequireRole::Admin));
169    ///
170    /// router.get("/", home).middleware(kernel::web());
171    /// ```
172    pub fn middleware(&mut self, middleware: impl IntoMiddlewareStack) -> &mut Self {
173        self.middleware =
174            std::mem::take(&mut self.middleware).with_stack(middleware.into_middleware_stack());
175        self
176    }
177
178    /// Skip a middleware this route would otherwise inherit from its group.
179    ///
180    /// By type, because that is the only identity a value has:
181    ///
182    /// ```ignore
183    /// router.post("/webhooks/stripe", stripe)
184    ///     .without_middleware::<VerifyCsrfToken>();
185    /// ```
186    ///
187    /// Matches the **concrete type**, so excluding `ThrottleRequests` removes
188    /// every rate limiter the group applied, not one particular configuration
189    /// of it. Where that is too blunt, do not put it in the group.
190    pub fn without_middleware<M: Middleware>(&mut self) -> &mut Self {
191        self.excluded_middleware.push(TypeId::of::<M>());
192        self
193    }
194
195    /// Constrain a route parameter.
196    pub fn where_param(
197        &mut self,
198        param: impl Into<String>,
199        constraint: ParamConstraint,
200    ) -> &mut Self {
201        self.constraints.insert(param.into(), constraint);
202        self
203    }
204
205    /// Constrain a parameter to digits.
206    pub fn where_number(&mut self, param: impl Into<String>) -> &mut Self {
207        self.where_param(param, ParamConstraint::Number)
208    }
209
210    /// Constrain a parameter to a slug.
211    pub fn where_slug(&mut self, param: impl Into<String>) -> &mut Self {
212        self.where_param(param, ParamConstraint::Slug)
213    }
214
215    /// Constrain a parameter to a UUID.
216    pub fn where_uuid(&mut self, param: impl Into<String>) -> &mut Self {
217        self.where_param(param, ParamConstraint::Uuid)
218    }
219
220    /// Constrain a parameter to a fixed set of values.
221    pub fn where_in(
222        &mut self,
223        param: impl Into<String>,
224        values: impl IntoIterator<Item = impl Into<String>>,
225    ) -> &mut Self {
226        self.where_param(param, ParamConstraint::In(values.into_iter().map(Into::into).collect()))
227    }
228
229    // --- accessors ---------------------------------------------------------
230
231    /// The methods this route answers.
232    pub fn methods(&self) -> &[Method] {
233        &self.methods
234    }
235
236    /// The URI pattern, normalised to start with `/`.
237    pub fn uri(&self) -> &str {
238        &self.uri
239    }
240
241    /// The compiled segments.
242    pub fn segments(&self) -> &[Segment] {
243        &self.segments
244    }
245
246    /// The route's name, if it has one.
247    pub fn route_name(&self) -> Option<&str> {
248        self.name.as_deref()
249    }
250
251    /// The middleware attached to this route, outermost first.
252    pub fn middleware_stack(&self) -> &MiddlewareStack {
253        &self.middleware
254    }
255
256    /// The types this route opts out of.
257    pub fn excluded_middleware(&self) -> &[TypeId] {
258        &self.excluded_middleware
259    }
260
261    /// The handler.
262    pub fn handler(&self) -> &Arc<dyn RouteHandler> {
263        &self.handler
264    }
265
266    /// The parameter names this route captures, in order.
267    pub fn param_names(&self) -> Vec<&str> {
268        self.segments
269            .iter()
270            .filter_map(|segment| match segment {
271                Segment::Static(_) => None,
272                Segment::Param(name) | Segment::OptionalParam(name) | Segment::Wildcard(name) => {
273                    Some(name.as_str())
274                }
275            })
276            .collect()
277    }
278
279    // --- internal, used by the router --------------------------------------
280
281    pub(crate) fn prefix_with(&mut self, prefix: &str) {
282        if prefix.is_empty() {
283            return;
284        }
285        let joined = format!("/{}/{}", prefix.trim_matches('/'), self.uri.trim_start_matches('/'));
286        self.uri = normalise_uri(&joined);
287        self.segments = compile(&self.uri);
288    }
289
290    pub(crate) fn prepend_middleware(&mut self, outer: &MiddlewareStack) {
291        // Group middleware runs outside the route's own, so it goes first.
292        self.middleware.prepend(outer);
293    }
294
295    pub(crate) fn prepend_name(&mut self, prefix: &str) {
296        if prefix.is_empty() {
297            return;
298        }
299        // A group name prefix alone does not name an unnamed route — `admin.`
300        // is a prefix, not a route name — so `None` stays `None`.
301        self.name = self.name.take().map(|name| format!("{prefix}{name}"));
302    }
303
304    pub(crate) fn add_constraints(&mut self, constraints: &HashMap<String, ParamConstraint>) {
305        for (param, constraint) in constraints {
306            // A route's own `where` wins over the group's.
307            self.constraints.entry(param.clone()).or_insert_with(|| constraint.clone());
308        }
309    }
310
311    /// Match `path` against this route's pattern, returning the captured
312    /// parameters. `None` if the shape differs or a constraint rejects a value.
313    pub fn match_path(&self, path: &str) -> Option<HashMap<String, String>> {
314        let actual: Vec<&str> = split_path(path);
315        let mut params = HashMap::new();
316        let mut index = 0;
317
318        for (position, segment) in self.segments.iter().enumerate() {
319            match segment {
320                Segment::Static(literal) => {
321                    if actual.get(index) != Some(&literal.as_str()) {
322                        return None;
323                    }
324                    index += 1;
325                }
326                Segment::Param(name) => {
327                    let value = decode(actual.get(index)?);
328                    if !self.allows(name, &value) {
329                        return None;
330                    }
331                    params.insert(name.clone(), value);
332                    index += 1;
333                }
334                Segment::OptionalParam(name) => {
335                    let Some(raw) = actual.get(index) else {
336                        // Absent: legal only if nothing after it is required.
337                        return self.rest_is_optional(position + 1).then_some(params);
338                    };
339                    let value = decode(raw);
340                    if !self.allows(name, &value) {
341                        return None;
342                    }
343                    params.insert(name.clone(), value);
344                    index += 1;
345                }
346                Segment::Wildcard(name) => {
347                    // Swallows the remainder, slashes included.
348                    let rest = actual[index.min(actual.len())..].join("/");
349                    let value = decode(&rest);
350                    if !self.allows(name, &value) {
351                        return None;
352                    }
353                    params.insert(name.clone(), value);
354                    index = actual.len();
355                }
356            }
357        }
358
359        (index == actual.len()).then_some(params)
360    }
361
362    fn rest_is_optional(&self, from: usize) -> bool {
363        self.segments[from..]
364            .iter()
365            .all(|s| matches!(s, Segment::OptionalParam(_) | Segment::Wildcard(_)))
366    }
367
368    fn allows(&self, param: &str, value: &str) -> bool {
369        match self.constraints.get(param) {
370            Some(constraint) => constraint.allows(value),
371            None => true,
372        }
373    }
374
375    /// Whether `path` matches, ignoring the method — used to answer `405`
376    /// instead of `404` when a route exists but not for this method.
377    pub fn matches_path_only(&self, path: &str) -> bool {
378        self.match_path(path).is_some()
379    }
380
381    /// Whether this route answers `method`.
382    pub fn accepts(&self, method: &Method) -> bool {
383        self.methods.iter().any(|m| m == method)
384    }
385}
386
387impl std::fmt::Debug for Route {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.debug_struct("Route")
390            .field("methods", &self.methods.iter().map(|m| m.as_str()).collect::<Vec<_>>())
391            .field("uri", &self.uri)
392            .field("name", &self.name)
393            .field("middleware", &self.middleware)
394            .finish()
395    }
396}
397
398/// `posts/{post}` → `/posts/{post}`; `//a//b/` → `/a/b`. The root is `/`.
399pub fn normalise_uri(uri: &str) -> String {
400    let trimmed: Vec<&str> = split_path(uri);
401    if trimmed.is_empty() {
402        return "/".to_string();
403    }
404    format!("/{}", trimmed.join("/"))
405}
406
407/// Split a path into non-empty segments, so `/a//b/` and `a/b` agree.
408fn split_path(path: &str) -> Vec<&str> {
409    path.split('/').filter(|s| !s.is_empty()).collect()
410}
411
412fn decode(raw: &str) -> String {
413    percent_decode_str(raw).decode_utf8_lossy().into_owned()
414}
415
416/// Compile a URI pattern into segments.
417pub fn compile(uri: &str) -> Vec<Segment> {
418    split_path(uri)
419        .into_iter()
420        .map(|piece| {
421            let Some(inner) = piece.strip_prefix('{').and_then(|p| p.strip_suffix('}')) else {
422                return Segment::Static(piece.to_string());
423            };
424            if let Some(name) = inner.strip_suffix('?') {
425                Segment::OptionalParam(name.to_string())
426            } else if let Some(name) = inner.strip_suffix('*') {
427                Segment::Wildcard(name.to_string())
428            } else {
429                Segment::Param(inner.to_string())
430            }
431        })
432        .collect()
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use rainier_http::{Request, Response};
439    use rainier_support::BoxedFuture;
440
441    struct Noop;
442    impl RouteHandler for Noop {
443        fn call(&self, _: Request) -> BoxedFuture<Response> {
444            Box::pin(async { Response::no_content() })
445        }
446    }
447
448    fn route(uri: &str) -> Route {
449        Route::new(vec![Method::GET], uri, Arc::new(Noop))
450    }
451
452    fn matched(route: &Route, path: &str) -> Option<Vec<(String, String)>> {
453        route.match_path(path).map(|params| {
454            let mut pairs: Vec<_> = params.into_iter().collect();
455            pairs.sort();
456            pairs
457        })
458    }
459
460    #[test]
461    fn normalises_uris() {
462        assert_eq!(normalise_uri("posts"), "/posts");
463        assert_eq!(normalise_uri("/posts/"), "/posts");
464        assert_eq!(normalise_uri("//a//b/"), "/a/b");
465        assert_eq!(normalise_uri(""), "/");
466        assert_eq!(normalise_uri("/"), "/");
467    }
468
469    #[test]
470    fn compiles_the_segment_kinds() {
471        assert_eq!(
472            compile("/posts/{post}/comments/{comment?}/{rest*}"),
473            vec![
474                Segment::Static("posts".into()),
475                Segment::Param("post".into()),
476                Segment::Static("comments".into()),
477                Segment::OptionalParam("comment".into()),
478                Segment::Wildcard("rest".into()),
479            ]
480        );
481    }
482
483    #[test]
484    fn matches_static_paths() {
485        let route = route("/posts");
486        assert_eq!(matched(&route, "/posts"), Some(vec![]));
487        assert_eq!(matched(&route, "/posts/"), Some(vec![]));
488        assert_eq!(matched(&route, "/other"), None);
489        assert_eq!(matched(&route, "/posts/1"), None);
490    }
491
492    #[test]
493    fn matches_the_root() {
494        let route = route("/");
495        assert_eq!(matched(&route, "/"), Some(vec![]));
496        assert_eq!(matched(&route, "/a"), None);
497    }
498
499    #[test]
500    fn captures_parameters() {
501        let route = route("/posts/{post}/comments/{comment}");
502        assert_eq!(
503            matched(&route, "/posts/7/comments/3"),
504            Some(vec![("comment".into(), "3".into()), ("post".into(), "7".into())])
505        );
506        assert_eq!(matched(&route, "/posts/7/comments"), None);
507    }
508
509    #[test]
510    fn percent_decodes_captured_values() {
511        let route = route("/tags/{tag}");
512        assert_eq!(
513            matched(&route, "/tags/hello%20world"),
514            Some(vec![("tag".into(), "hello world".into())])
515        );
516    }
517
518    #[test]
519    fn optional_parameters_may_be_absent_when_trailing() {
520        let route = route("/posts/{post?}");
521        assert_eq!(matched(&route, "/posts"), Some(vec![]));
522        assert_eq!(matched(&route, "/posts/7"), Some(vec![("post".into(), "7".into())]));
523    }
524
525    #[test]
526    fn an_optional_parameter_before_a_required_one_cannot_be_skipped() {
527        let route = route("/a/{maybe?}/b");
528        assert_eq!(matched(&route, "/a/b"), None, "`b` is required, so `maybe` cannot vanish");
529        assert_eq!(matched(&route, "/a/x/b"), Some(vec![("maybe".into(), "x".into())]));
530    }
531
532    #[test]
533    fn a_wildcard_swallows_the_rest_including_slashes() {
534        let route = route("/files/{path*}");
535        assert_eq!(
536            matched(&route, "/files/a/b/c.txt"),
537            Some(vec![("path".into(), "a/b/c.txt".into())])
538        );
539        assert_eq!(matched(&route, "/files"), Some(vec![("path".into(), "".into())]));
540    }
541
542    #[test]
543    fn constraints_reject_non_matching_values() {
544        let mut route = route("/posts/{post}");
545        route.where_number("post");
546        assert!(route.match_path("/posts/7").is_some());
547        assert!(route.match_path("/posts/create").is_none());
548    }
549
550    #[test]
551    fn every_constraint_kind() {
552        assert!(ParamConstraint::Number.allows("123"));
553        assert!(!ParamConstraint::Number.allows("12a"));
554        assert!(!ParamConstraint::Number.allows(""));
555
556        assert!(ParamConstraint::Alpha.allows("abc"));
557        assert!(!ParamConstraint::Alpha.allows("a1"));
558
559        assert!(ParamConstraint::AlphaNumeric.allows("a1"));
560        assert!(!ParamConstraint::AlphaNumeric.allows("a-1"));
561
562        assert!(ParamConstraint::Slug.allows("hello-world_1"));
563        assert!(!ParamConstraint::Slug.allows("hello world"));
564
565        assert!(ParamConstraint::Uuid.allows("123e4567-e89b-12d3-a456-426614174000"));
566        assert!(!ParamConstraint::Uuid.allows("123e4567-e89b-12d3-a456"));
567        assert!(!ParamConstraint::Uuid.allows("123e4567xe89b-12d3-a456-426614174000"));
568
569        let one_of = ParamConstraint::In(vec!["draft".into(), "live".into()]);
570        assert!(one_of.allows("draft"));
571        assert!(!one_of.allows("other"));
572
573        let custom = ParamConstraint::Custom(Arc::new(|v: &str| v.starts_with("x")));
574        assert!(custom.allows("xyz"));
575        assert!(!custom.allows("abc"));
576    }
577
578    #[test]
579    fn get_routes_also_answer_head() {
580        let route = route("/posts");
581        assert!(route.accepts(&Method::GET));
582        assert!(route.accepts(&Method::HEAD));
583        assert!(!route.accepts(&Method::POST));
584    }
585
586    #[test]
587    fn head_is_not_added_to_non_get_routes() {
588        let route = Route::new(vec![Method::POST], "/posts", Arc::new(Noop));
589        assert!(!route.accepts(&Method::HEAD));
590    }
591
592    #[test]
593    fn prefixing_rewrites_the_uri_and_recompiles() {
594        let mut route = route("/users/{user}");
595        route.prefix_with("admin");
596        assert_eq!(route.uri(), "/admin/users/{user}");
597        assert!(route.match_path("/admin/users/1").is_some());
598        assert!(route.match_path("/users/1").is_none());
599    }
600
601    struct Own;
602    struct Group;
603
604    macro_rules! passthrough {
605        ($ty:ty) => {
606            #[async_trait::async_trait]
607            impl rainier_middleware::Middleware for $ty {
608                async fn handle(
609                    &self,
610                    request: rainier_http::Request,
611                    next: rainier_middleware::Next,
612                ) -> rainier_http::Response {
613                    next.run(request).await
614                }
615                fn name(&self) -> &'static str {
616                    stringify!($ty)
617                }
618            }
619        };
620    }
621
622    passthrough!(Own);
623    passthrough!(Group);
624
625    #[test]
626    fn group_middleware_runs_outside_route_middleware() {
627        let mut route = route("/x");
628        route.middleware(Own);
629        route.prepend_middleware(&MiddlewareStack::new().with(Group));
630
631        assert_eq!(route.middleware_stack().labels(), vec!["Group", "Own"]);
632    }
633
634    #[test]
635    fn a_route_opts_out_by_type_not_by_name() {
636        let mut route = route("/x");
637        route.middleware((Group, Own)).without_middleware::<Group>();
638
639        assert_eq!(route.excluded_middleware(), &[TypeId::of::<Group>()]);
640        assert_eq!(
641            route.middleware_stack().labels(),
642            vec!["Group", "Own"],
643            "exclusion is applied when the pipeline is built, not when it is declared"
644        );
645    }
646
647    #[test]
648    fn a_name_prefix_only_applies_to_named_routes() {
649        let mut named = route("/x");
650        named.name("index");
651        named.prepend_name("admin.");
652        assert_eq!(named.route_name(), Some("admin.index"));
653
654        let mut unnamed = route("/y");
655        unnamed.prepend_name("admin.");
656        assert_eq!(unnamed.route_name(), None);
657    }
658
659    #[test]
660    fn a_route_constraint_beats_the_groups() {
661        let mut route = route("/{id}");
662        route.where_param("id", ParamConstraint::Alpha);
663        let mut group = HashMap::new();
664        group.insert("id".to_string(), ParamConstraint::Number);
665        route.add_constraints(&group);
666
667        assert!(route.match_path("/abc").is_some(), "the route's own Alpha should win");
668        assert!(route.match_path("/123").is_none());
669    }
670
671    #[test]
672    fn lists_its_parameter_names() {
673        let route = route("/posts/{post}/tags/{tag?}/{rest*}");
674        assert_eq!(route.param_names(), vec!["post", "tag", "rest"]);
675    }
676}