1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Segment {
17 Static(String),
19 Param(String),
21 OptionalParam(String),
23 Wildcard(String),
25}
26
27#[derive(Clone)]
34pub enum ParamConstraint {
35 Number,
37 Alpha,
39 AlphaNumeric,
41 Slug,
43 Uuid,
45 In(Vec<String>),
47 Custom(Arc<dyn Fn(&str) -> bool + Send + Sync>),
49}
50
51impl ParamConstraint {
52 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#[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 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 pub fn name(&mut self, name: impl Into<String>) -> &mut Self {
153 self.name = Some(name.into());
154 self
155 }
156
157 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 pub fn without_middleware<M: Middleware>(&mut self) -> &mut Self {
191 self.excluded_middleware.push(TypeId::of::<M>());
192 self
193 }
194
195 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 pub fn where_number(&mut self, param: impl Into<String>) -> &mut Self {
207 self.where_param(param, ParamConstraint::Number)
208 }
209
210 pub fn where_slug(&mut self, param: impl Into<String>) -> &mut Self {
212 self.where_param(param, ParamConstraint::Slug)
213 }
214
215 pub fn where_uuid(&mut self, param: impl Into<String>) -> &mut Self {
217 self.where_param(param, ParamConstraint::Uuid)
218 }
219
220 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 pub fn methods(&self) -> &[Method] {
233 &self.methods
234 }
235
236 pub fn uri(&self) -> &str {
238 &self.uri
239 }
240
241 pub fn segments(&self) -> &[Segment] {
243 &self.segments
244 }
245
246 pub fn route_name(&self) -> Option<&str> {
248 self.name.as_deref()
249 }
250
251 pub fn middleware_stack(&self) -> &MiddlewareStack {
253 &self.middleware
254 }
255
256 pub fn excluded_middleware(&self) -> &[TypeId] {
258 &self.excluded_middleware
259 }
260
261 pub fn handler(&self) -> &Arc<dyn RouteHandler> {
263 &self.handler
264 }
265
266 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 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 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 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 self.constraints.entry(param.clone()).or_insert_with(|| constraint.clone());
308 }
309 }
310
311 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 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 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 pub fn matches_path_only(&self, path: &str) -> bool {
378 self.match_path(path).is_some()
379 }
380
381 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
398pub 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
407fn 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
416pub 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}