1use crate::request::Method;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum TrailingSlashMode {
31 #[default]
33 Strict,
34 Redirect,
37 RedirectWithSlash,
40 MatchBoth,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Converter {
66 Str,
68 Int,
70 Float,
72 Uuid,
74 Path,
76}
77
78impl Converter {
79 #[must_use]
85 pub fn matches(&self, value: &str) -> bool {
86 match self {
87 Self::Str => value != ".." && value != ".",
88 Self::Int => value.parse::<i64>().is_ok(),
89 Self::Float => value.parse::<f64>().is_ok_and(f64::is_finite),
90 Self::Uuid => is_uuid(value),
91 Self::Path => !path_has_traversal(value),
92 }
93 }
94
95 #[must_use]
97 pub fn parse(s: &str) -> Self {
98 match s {
99 "int" => Self::Int,
100 "float" => Self::Float,
101 "uuid" => Self::Uuid,
102 "path" => Self::Path,
103 _ => Self::Str,
104 }
105 }
106}
107
108fn path_has_traversal(value: &str) -> bool {
113 value.split('/').any(|seg| seg == ".." || seg == ".")
114}
115
116fn is_uuid(s: &str) -> bool {
117 if s.len() != 36 {
119 return false;
120 }
121 let parts: Vec<_> = s.split('-').collect();
122 if parts.len() != 5 {
123 return false;
124 }
125 parts[0].len() == 8
126 && parts[1].len() == 4
127 && parts[2].len() == 4
128 && parts[3].len() == 4
129 && parts[4].len() == 12
130 && parts
131 .iter()
132 .all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
133}
134
135#[derive(Debug, Clone)]
137pub struct ParamInfo {
138 pub name: String,
140 pub converter: Converter,
142}
143
144#[derive(Debug, Clone)]
146pub enum PathSegment {
147 Static(String),
149 Param(ParamInfo),
151}
152
153#[derive(Debug, Clone)]
155pub struct RoutePattern {
156 pub pattern: String,
158 pub segments: Vec<PathSegment>,
160 pub has_path_converter: bool,
162}
163
164impl RoutePattern {
165 #[must_use]
173 pub fn parse(pattern: &str) -> Self {
174 let segments = parse_path_segments(pattern);
175 let has_path_converter = matches!(
176 segments.last(),
177 Some(PathSegment::Param(ParamInfo {
178 converter: Converter::Path,
179 ..
180 }))
181 );
182
183 Self {
184 pattern: pattern.to_string(),
185 segments,
186 has_path_converter,
187 }
188 }
189
190 #[must_use]
195 pub fn match_path<'a>(&self, path: &'a str) -> Option<Vec<(String, &'a str)>> {
196 let path_ranges = segment_ranges(path);
197 let mut path_segments: Vec<&'a str> = Vec::with_capacity(path_ranges.len());
198 for (start, end) in &path_ranges {
199 path_segments.push(&path[*start..*end]);
200 }
201
202 let mut params = Vec::new();
203 let mut path_idx = 0;
204 let last_end = path_ranges.last().map_or(0, |(_, end)| *end);
205
206 for segment in &self.segments {
207 match segment {
208 PathSegment::Static(expected) => {
209 if path_idx >= path_segments.len() || path_segments[path_idx] != expected {
210 return None;
211 }
212 path_idx += 1;
213 }
214 PathSegment::Param(info) => {
215 if path_idx >= path_segments.len() {
216 return None;
217 }
218
219 if info.converter == Converter::Path {
220 let start = path_ranges[path_idx].0;
222 let value = &path[start..last_end];
223 if path_has_traversal(value) {
225 return None;
226 }
227 params.push((info.name.clone(), value));
228 path_idx = path_segments.len();
230 } else {
231 let value = path_segments[path_idx];
232 if !info.converter.matches(value) {
233 return None;
234 }
235 params.push((info.name.clone(), value));
236 path_idx += 1;
237 }
238 }
239 }
240 }
241
242 if path_idx != path_segments.len() && !self.has_path_converter {
244 return None;
245 }
246
247 Some(params)
248 }
249
250 #[must_use]
254 pub fn could_match(&self, path: &str) -> bool {
255 self.match_path(path).is_some()
256 }
257}
258
259fn parse_path_segments(path: &str) -> Vec<PathSegment> {
260 path.split('/')
261 .filter(|s| !s.is_empty())
262 .map(|s| {
263 if s.starts_with('{') && s.ends_with('}') {
264 let inner = &s[1..s.len() - 1];
265 let (name, converter) = if let Some(pos) = inner.find(':') {
266 let conv = Converter::parse(&inner[pos + 1..]);
267 (inner[..pos].to_string(), conv)
268 } else {
269 (inner.to_string(), Converter::Str)
270 };
271 PathSegment::Param(ParamInfo { name, converter })
272 } else {
273 PathSegment::Static(s.to_string())
274 }
275 })
276 .collect()
277}
278
279fn segment_ranges(path: &str) -> Vec<(usize, usize)> {
280 let bytes = path.as_bytes();
281 let mut ranges = Vec::new();
282 let mut idx = 0;
283 while idx < bytes.len() {
284 while idx < bytes.len() && bytes[idx] == b'/' {
286 idx += 1;
287 }
288 if idx >= bytes.len() {
289 break;
290 }
291 let start = idx;
292 while idx < bytes.len() && bytes[idx] != b'/' {
294 idx += 1;
295 }
296 ranges.push((start, idx));
297 }
298 ranges
299}
300
301#[derive(Debug)]
303pub enum RouteLookup<'a, T> {
304 Match {
306 route: &'a T,
308 params: Vec<(String, String)>,
310 },
311 MethodNotAllowed {
313 allowed: Vec<Method>,
315 },
316 Redirect {
320 target: String,
322 },
323 NotFound,
325}
326
327pub struct RouteTable<T> {
332 routes: Vec<(Method, RoutePattern, T)>,
333}
334
335impl<T> RouteTable<T> {
336 #[must_use]
338 pub fn new() -> Self {
339 Self { routes: Vec::new() }
340 }
341
342 pub fn add(&mut self, method: Method, pattern: &str, data: T) {
344 let parsed = RoutePattern::parse(pattern);
345 self.routes.push((method, parsed, data));
346 }
347
348 #[must_use]
350 pub fn lookup(&self, path: &str, method: Method) -> RouteLookup<'_, T> {
351 for (route_method, pattern, data) in &self.routes {
353 if let Some(params) = pattern.match_path(path) {
354 let owned_params: Vec<(String, String)> = params
356 .into_iter()
357 .map(|(name, value)| (name, value.to_string()))
358 .collect();
359
360 if *route_method == method {
361 return RouteLookup::Match {
362 route: data,
363 params: owned_params,
364 };
365 }
366 if method == Method::Head && *route_method == Method::Get {
368 return RouteLookup::Match {
369 route: data,
370 params: owned_params,
371 };
372 }
373 }
374 }
375
376 let mut allowed_methods: Vec<Method> = Vec::new();
378 for (route_method, pattern, _) in &self.routes {
379 if pattern.could_match(path) && !allowed_methods.contains(route_method) {
380 allowed_methods.push(*route_method);
381 }
382 }
383
384 if !allowed_methods.is_empty() {
385 if allowed_methods.contains(&Method::Get) && !allowed_methods.contains(&Method::Head) {
387 allowed_methods.push(Method::Head);
388 }
389 allowed_methods.sort_by_key(|m| method_order(*m));
391 return RouteLookup::MethodNotAllowed {
392 allowed: allowed_methods,
393 };
394 }
395
396 RouteLookup::NotFound
397 }
398
399 #[must_use]
407 pub fn lookup_with_trailing_slash(
408 &self,
409 path: &str,
410 method: Method,
411 mode: TrailingSlashMode,
412 ) -> RouteLookup<'_, T> {
413 let result = self.lookup(path, method);
415 if !matches!(result, RouteLookup::NotFound) {
416 return result;
417 }
418
419 if mode == TrailingSlashMode::Strict {
421 return result;
422 }
423
424 let has_trailing_slash = path.len() > 1 && path.ends_with('/');
426 let alt_path = if has_trailing_slash {
427 &path[..path.len() - 1]
429 } else {
430 return self.lookup_with_trailing_slash_add(path, method, mode);
432 };
433
434 let alt_result = self.lookup(alt_path, method);
435 match (&alt_result, mode) {
436 (RouteLookup::Match { .. }, TrailingSlashMode::Redirect) => {
437 RouteLookup::Redirect {
439 target: alt_path.to_string(),
440 }
441 }
442 (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
443 RouteLookup::Match {
445 route,
446 params: params.clone(),
447 }
448 }
449 (RouteLookup::MethodNotAllowed { allowed: _ }, TrailingSlashMode::Redirect) => {
450 RouteLookup::Redirect {
452 target: alt_path.to_string(),
453 }
454 }
455 (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
456 RouteLookup::MethodNotAllowed {
458 allowed: allowed.clone(),
459 }
460 }
461 _ => result, }
463 }
464
465 fn lookup_with_trailing_slash_add(
467 &self,
468 path: &str,
469 method: Method,
470 mode: TrailingSlashMode,
471 ) -> RouteLookup<'_, T> {
472 let with_slash = format!("{}/", path);
474 let alt_result = self.lookup(&with_slash, method);
475
476 match (&alt_result, mode) {
477 (RouteLookup::Match { .. }, TrailingSlashMode::RedirectWithSlash) => {
478 RouteLookup::Redirect { target: with_slash }
480 }
481 (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
482 RouteLookup::Match {
484 route,
485 params: params.clone(),
486 }
487 }
488 (
489 RouteLookup::MethodNotAllowed { allowed: _ },
490 TrailingSlashMode::RedirectWithSlash,
491 ) => {
492 RouteLookup::Redirect { target: with_slash }
494 }
495 (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
496 RouteLookup::MethodNotAllowed {
498 allowed: allowed.clone(),
499 }
500 }
501 _ => RouteLookup::NotFound,
502 }
503 }
504
505 #[must_use]
507 pub fn len(&self) -> usize {
508 self.routes.len()
509 }
510
511 #[must_use]
513 pub fn is_empty(&self) -> bool {
514 self.routes.is_empty()
515 }
516}
517
518impl<T> Default for RouteTable<T> {
519 fn default() -> Self {
520 Self::new()
521 }
522}
523
524#[must_use]
529pub fn method_order(method: Method) -> u8 {
530 match method {
531 Method::Get => 0,
532 Method::Head => 1,
533 Method::Post => 2,
534 Method::Put => 3,
535 Method::Delete => 4,
536 Method::Patch => 5,
537 Method::Options => 6,
538 Method::Trace => 7,
539 }
540}
541
542#[must_use]
544pub fn format_allow_header(methods: &[Method]) -> String {
545 methods
546 .iter()
547 .map(|m| m.as_str())
548 .collect::<Vec<_>>()
549 .join(", ")
550}
551
552use std::collections::HashMap;
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub enum UrlError {
569 RouteNotFound { name: String },
571 MissingParam { name: String, param: String },
573 InvalidParam {
575 name: String,
576 param: String,
577 value: String,
578 },
579}
580
581impl std::fmt::Display for UrlError {
582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 match self {
584 Self::RouteNotFound { name } => {
585 write!(f, "route '{}' not found", name)
586 }
587 Self::MissingParam { name, param } => {
588 write!(f, "route '{}' requires parameter '{}'", name, param)
589 }
590 Self::InvalidParam { name, param, value } => {
591 write!(
592 f,
593 "route '{}' parameter '{}': invalid value '{}'",
594 name, param, value
595 )
596 }
597 }
598 }
599}
600
601impl std::error::Error for UrlError {}
602
603#[derive(Debug, Clone, Default)]
623pub struct UrlRegistry {
624 routes: HashMap<String, RoutePattern>,
626 root_path: String,
628}
629
630impl UrlRegistry {
631 #[must_use]
633 pub fn new() -> Self {
634 Self {
635 routes: HashMap::new(),
636 root_path: String::new(),
637 }
638 }
639
640 #[must_use]
654 pub fn with_root_path(root_path: impl Into<String>) -> Self {
655 let mut path = root_path.into();
656 while path.ends_with('/') {
658 path.pop();
659 }
660 Self {
661 routes: HashMap::new(),
662 root_path: path,
663 }
664 }
665
666 pub fn set_root_path(&mut self, root_path: impl Into<String>) {
668 let mut path = root_path.into();
669 while path.ends_with('/') {
670 path.pop();
671 }
672 self.root_path = path;
673 }
674
675 #[must_use]
677 pub fn root_path(&self) -> &str {
678 &self.root_path
679 }
680
681 pub fn register(&mut self, name: impl Into<String>, pattern: &str) {
688 let name = name.into();
689 let parsed = RoutePattern::parse(pattern);
690 self.routes.insert(name, parsed);
691 }
692
693 #[must_use]
695 pub fn has_route(&self, name: &str) -> bool {
696 self.routes.contains_key(name)
697 }
698
699 #[must_use]
701 pub fn get_pattern(&self, name: &str) -> Option<&str> {
702 self.routes.get(name).map(|p| p.pattern.as_str())
703 }
704
705 pub fn url_for(
731 &self,
732 name: &str,
733 params: &[(&str, &str)],
734 query: &[(&str, &str)],
735 ) -> Result<String, UrlError> {
736 let pattern = self
737 .routes
738 .get(name)
739 .ok_or_else(|| UrlError::RouteNotFound {
740 name: name.to_string(),
741 })?;
742
743 let param_map: HashMap<&str, &str> = params.iter().copied().collect();
745
746 let mut path = String::new();
748 if !self.root_path.is_empty() {
749 path.push_str(&self.root_path);
750 }
751
752 let has_segments = !pattern.segments.is_empty();
754
755 for segment in &pattern.segments {
756 path.push('/');
757 match segment {
758 PathSegment::Static(s) => {
759 path.push_str(s);
760 }
761 PathSegment::Param(info) => {
762 let value = *param_map.get(info.name.as_str()).ok_or_else(|| {
763 UrlError::MissingParam {
764 name: name.to_string(),
765 param: info.name.clone(),
766 }
767 })?;
768
769 if !info.converter.matches(value) {
771 return Err(UrlError::InvalidParam {
772 name: name.to_string(),
773 param: info.name.clone(),
774 value: value.to_string(),
775 });
776 }
777
778 if info.converter == Converter::Path {
780 path.push_str(value);
781 } else {
782 path.push_str(&url_encode_path_segment(value));
783 }
784 }
785 }
786 }
787
788 if path.is_empty() || (!has_segments && !self.root_path.is_empty()) {
791 path.push('/');
792 }
793
794 if !query.is_empty() {
796 path.push('?');
797 for (i, (key, value)) in query.iter().enumerate() {
798 if i > 0 {
799 path.push('&');
800 }
801 path.push_str(&url_encode(key));
802 path.push('=');
803 path.push_str(&url_encode(value));
804 }
805 }
806
807 Ok(path)
808 }
809
810 #[must_use]
812 pub fn len(&self) -> usize {
813 self.routes.len()
814 }
815
816 #[must_use]
818 pub fn is_empty(&self) -> bool {
819 self.routes.is_empty()
820 }
821
822 pub fn route_names(&self) -> impl Iterator<Item = &str> {
824 self.routes.keys().map(String::as_str)
825 }
826}
827
828#[must_use]
832pub fn url_encode(s: &str) -> String {
833 let mut result = String::with_capacity(s.len() * 3);
834 for byte in s.bytes() {
835 match byte {
836 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
838 result.push(byte as char);
839 }
840 _ => {
842 result.push('%');
843 result.push(
844 char::from_digit(u32::from(byte >> 4), 16)
845 .unwrap()
846 .to_ascii_uppercase(),
847 );
848 result.push(
849 char::from_digit(u32::from(byte & 0xF), 16)
850 .unwrap()
851 .to_ascii_uppercase(),
852 );
853 }
854 }
855 }
856 result
857}
858
859#[must_use]
864pub fn url_encode_path_segment(s: &str) -> String {
865 let mut result = String::with_capacity(s.len() * 3);
866 for byte in s.bytes() {
867 match byte {
868 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
870 result.push(byte as char);
871 }
872 _ => {
874 result.push('%');
875 result.push(
876 char::from_digit(u32::from(byte >> 4), 16)
877 .unwrap()
878 .to_ascii_uppercase(),
879 );
880 result.push(
881 char::from_digit(u32::from(byte & 0xF), 16)
882 .unwrap()
883 .to_ascii_uppercase(),
884 );
885 }
886 }
887 }
888 result
889}
890
891#[must_use]
897pub fn url_decode(s: &str) -> Option<String> {
898 let mut result = Vec::with_capacity(s.len());
899 let mut bytes = s.bytes();
900
901 while let Some(byte) = bytes.next() {
902 if byte == b'%' {
903 let hi = bytes.next()?;
904 let lo = bytes.next()?;
905 let hi = char::from(hi).to_digit(16)?;
906 let lo = char::from(lo).to_digit(16)?;
907 result.push((hi * 16 + lo) as u8);
908 } else if byte == b'+' {
909 result.push(b' ');
911 } else {
912 result.push(byte);
913 }
914 }
915
916 String::from_utf8(result).ok()
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 #[test]
924 fn converter_str_matches_anything() {
925 assert!(Converter::Str.matches("hello"));
926 assert!(Converter::Str.matches("123"));
927 assert!(Converter::Str.matches(""));
928 }
929
930 #[test]
931 fn converter_int_matches_integers() {
932 assert!(Converter::Int.matches("123"));
933 assert!(Converter::Int.matches("-456"));
934 assert!(Converter::Int.matches("0"));
935 assert!(!Converter::Int.matches("12.34"));
936 assert!(!Converter::Int.matches("abc"));
937 assert!(!Converter::Int.matches(""));
938 }
939
940 #[test]
941 fn converter_float_matches_floats() {
942 assert!(Converter::Float.matches("3.14"));
943 assert!(Converter::Float.matches("42"));
944 assert!(Converter::Float.matches("-1.5"));
945 assert!(Converter::Float.matches("1e10"));
946 assert!(!Converter::Float.matches("abc"));
947 }
948
949 #[test]
950 fn converter_uuid_matches_uuids() {
951 assert!(Converter::Uuid.matches("550e8400-e29b-41d4-a716-446655440000"));
952 assert!(Converter::Uuid.matches("550E8400-E29B-41D4-A716-446655440000"));
953 assert!(!Converter::Uuid.matches("not-a-uuid"));
954 assert!(!Converter::Uuid.matches("550e8400e29b41d4a716446655440000")); }
956
957 #[test]
958 fn parse_static_path() {
959 let pattern = RoutePattern::parse("/users");
960 assert_eq!(pattern.segments.len(), 1);
961 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
962 }
963
964 #[test]
965 fn parse_path_with_param() {
966 let pattern = RoutePattern::parse("/users/{id}");
967 assert_eq!(pattern.segments.len(), 2);
968 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
969 assert!(
970 matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Str)
971 );
972 }
973
974 #[test]
975 fn parse_path_with_typed_param() {
976 let pattern = RoutePattern::parse("/items/{id:int}");
977 assert_eq!(pattern.segments.len(), 2);
978 assert!(
979 matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Int)
980 );
981 }
982
983 #[test]
984 fn parse_path_with_path_converter() {
985 let pattern = RoutePattern::parse("/files/{path:path}");
986 assert!(pattern.has_path_converter);
987 }
988
989 #[test]
990 fn match_static_path() {
991 let pattern = RoutePattern::parse("/users");
992 assert!(pattern.match_path("/users").is_some());
993 assert!(pattern.match_path("/items").is_none());
994 }
995
996 #[test]
997 fn match_path_extracts_params() {
998 let pattern = RoutePattern::parse("/users/{id}");
999 let params = pattern.match_path("/users/42").unwrap();
1000 assert_eq!(params.len(), 1);
1001 assert_eq!(params[0].0, "id");
1002 assert_eq!(params[0].1, "42");
1003 }
1004
1005 #[test]
1006 fn match_path_validates_int_converter() {
1007 let pattern = RoutePattern::parse("/items/{id:int}");
1008 assert!(pattern.match_path("/items/123").is_some());
1009 assert!(pattern.match_path("/items/abc").is_none());
1010 }
1011
1012 #[test]
1013 fn match_path_validates_uuid_converter() {
1014 let pattern = RoutePattern::parse("/objects/{id:uuid}");
1015 assert!(
1016 pattern
1017 .match_path("/objects/550e8400-e29b-41d4-a716-446655440000")
1018 .is_some()
1019 );
1020 assert!(pattern.match_path("/objects/not-a-uuid").is_none());
1021 }
1022
1023 #[test]
1024 fn match_path_converter_captures_slashes() {
1025 let pattern = RoutePattern::parse("/files/{path:path}");
1026 let params = pattern.match_path("/files/a/b/c.txt").unwrap();
1027 assert_eq!(params[0].0, "path");
1028 assert_eq!(params[0].1, "a/b/c.txt");
1029 }
1030
1031 #[test]
1032 fn match_multiple_params() {
1033 let pattern = RoutePattern::parse("/users/{user_id}/posts/{post_id}");
1034 let params = pattern.match_path("/users/42/posts/99").unwrap();
1035 assert_eq!(params.len(), 2);
1036 assert_eq!(params[0].0, "user_id");
1037 assert_eq!(params[0].1, "42");
1038 assert_eq!(params[1].0, "post_id");
1039 assert_eq!(params[1].1, "99");
1040 }
1041
1042 #[test]
1043 fn route_table_lookup_match() {
1044 let mut table: RouteTable<&str> = RouteTable::new();
1045 table.add(Method::Get, "/users/{id}", "get_user");
1046 table.add(Method::Post, "/users", "create_user");
1047
1048 match table.lookup("/users/42", Method::Get) {
1049 RouteLookup::Match { route, params } => {
1050 assert_eq!(*route, "get_user");
1051 assert_eq!(params[0].0, "id");
1052 assert_eq!(params[0].1, "42");
1053 }
1054 _ => panic!("Expected match"),
1055 }
1056 }
1057
1058 #[test]
1059 fn route_table_lookup_method_not_allowed() {
1060 let mut table: RouteTable<&str> = RouteTable::new();
1061 table.add(Method::Get, "/users", "get_users");
1062 table.add(Method::Post, "/users", "create_user");
1063
1064 match table.lookup("/users", Method::Delete) {
1065 RouteLookup::MethodNotAllowed { allowed } => {
1066 assert!(allowed.contains(&Method::Get));
1067 assert!(allowed.contains(&Method::Head));
1068 assert!(allowed.contains(&Method::Post));
1069 }
1070 _ => panic!("Expected MethodNotAllowed"),
1071 }
1072 }
1073
1074 #[test]
1075 fn route_table_lookup_not_found() {
1076 let mut table: RouteTable<&str> = RouteTable::new();
1077 table.add(Method::Get, "/users", "get_users");
1078
1079 assert!(matches!(
1080 table.lookup("/items", Method::Get),
1081 RouteLookup::NotFound
1082 ));
1083 }
1084
1085 #[test]
1086 fn route_table_head_matches_get() {
1087 let mut table: RouteTable<&str> = RouteTable::new();
1088 table.add(Method::Get, "/users", "get_users");
1089
1090 match table.lookup("/users", Method::Head) {
1091 RouteLookup::Match { route, .. } => {
1092 assert_eq!(*route, "get_users");
1093 }
1094 _ => panic!("Expected match for HEAD on GET route"),
1095 }
1096 }
1097
1098 #[test]
1099 fn format_allow_header_formats_methods() {
1100 let methods = vec![Method::Get, Method::Head, Method::Post];
1101 assert_eq!(format_allow_header(&methods), "GET, HEAD, POST");
1102 }
1103
1104 #[test]
1105 fn options_request_returns_method_not_allowed_with_allowed_methods() {
1106 let mut table: RouteTable<&str> = RouteTable::new();
1107 table.add(Method::Get, "/users", "get_users");
1108 table.add(Method::Post, "/users", "create_user");
1109
1110 match table.lookup("/users", Method::Options) {
1113 RouteLookup::MethodNotAllowed { allowed } => {
1114 assert!(allowed.contains(&Method::Get));
1115 assert!(allowed.contains(&Method::Head));
1116 assert!(allowed.contains(&Method::Post));
1117 }
1118 _ => panic!("Expected MethodNotAllowed for OPTIONS request"),
1119 }
1120 }
1121
1122 #[test]
1123 fn options_request_on_nonexistent_path_returns_not_found() {
1124 let mut table: RouteTable<&str> = RouteTable::new();
1125 table.add(Method::Get, "/users", "get_users");
1126
1127 match table.lookup("/items", Method::Options) {
1128 RouteLookup::NotFound => {}
1129 _ => panic!("Expected NotFound for OPTIONS on non-existent path"),
1130 }
1131 }
1132
1133 #[test]
1134 fn explicit_options_handler_matches() {
1135 let mut table: RouteTable<&str> = RouteTable::new();
1136 table.add(Method::Get, "/api/resource", "get_resource");
1137 table.add(Method::Options, "/api/resource", "options_resource");
1138
1139 match table.lookup("/api/resource", Method::Options) {
1140 RouteLookup::Match { route, .. } => {
1141 assert_eq!(*route, "options_resource");
1142 }
1143 _ => panic!("Expected match for explicit OPTIONS handler"),
1144 }
1145 }
1146
1147 #[test]
1148 fn method_order_returns_expected_ordering() {
1149 assert!(method_order(Method::Get) < method_order(Method::Post));
1150 assert!(method_order(Method::Head) < method_order(Method::Post));
1151 assert!(method_order(Method::Options) < method_order(Method::Trace));
1152 assert!(method_order(Method::Delete) < method_order(Method::Options));
1153 }
1154
1155 #[test]
1160 fn url_registry_new() {
1161 let registry = UrlRegistry::new();
1162 assert!(registry.is_empty());
1163 assert_eq!(registry.len(), 0);
1164 assert_eq!(registry.root_path(), "");
1165 }
1166
1167 #[test]
1168 fn url_registry_with_root_path() {
1169 let registry = UrlRegistry::with_root_path("/api/v1");
1170 assert_eq!(registry.root_path(), "/api/v1");
1171 }
1172
1173 #[test]
1174 fn url_registry_with_root_path_normalizes_trailing_slash() {
1175 let registry = UrlRegistry::with_root_path("/api/v1/");
1176 assert_eq!(registry.root_path(), "/api/v1");
1177
1178 let registry2 = UrlRegistry::with_root_path("/api///");
1179 assert_eq!(registry2.root_path(), "/api");
1180 }
1181
1182 #[test]
1183 fn url_registry_register_and_lookup() {
1184 let mut registry = UrlRegistry::new();
1185 registry.register("get_user", "/users/{id}");
1186
1187 assert!(registry.has_route("get_user"));
1188 assert!(!registry.has_route("nonexistent"));
1189 assert_eq!(registry.get_pattern("get_user"), Some("/users/{id}"));
1190 assert_eq!(registry.len(), 1);
1191 }
1192
1193 #[test]
1194 fn url_for_static_route() {
1195 let mut registry = UrlRegistry::new();
1196 registry.register("home", "/");
1197 registry.register("about", "/about");
1198
1199 let url = registry.url_for("home", &[], &[]).unwrap();
1200 assert_eq!(url, "/");
1201
1202 let url = registry.url_for("about", &[], &[]).unwrap();
1203 assert_eq!(url, "/about");
1204 }
1205
1206 #[test]
1207 fn url_for_with_path_param() {
1208 let mut registry = UrlRegistry::new();
1209 registry.register("get_user", "/users/{id}");
1210
1211 let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1212 assert_eq!(url, "/users/42");
1213 }
1214
1215 #[test]
1216 fn url_for_with_multiple_params() {
1217 let mut registry = UrlRegistry::new();
1218 registry.register("get_post", "/users/{user_id}/posts/{post_id}");
1219
1220 let url = registry
1221 .url_for("get_post", &[("user_id", "42"), ("post_id", "99")], &[])
1222 .unwrap();
1223 assert_eq!(url, "/users/42/posts/99");
1224 }
1225
1226 #[test]
1227 fn url_for_with_typed_param() {
1228 let mut registry = UrlRegistry::new();
1229 registry.register("get_item", "/items/{id:int}");
1230
1231 let url = registry.url_for("get_item", &[("id", "123")], &[]).unwrap();
1233 assert_eq!(url, "/items/123");
1234
1235 let result = registry.url_for("get_item", &[("id", "abc")], &[]);
1237 assert!(matches!(result, Err(UrlError::InvalidParam { .. })));
1238 }
1239
1240 #[test]
1241 fn url_for_with_uuid_param() {
1242 let mut registry = UrlRegistry::new();
1243 registry.register("get_object", "/objects/{id:uuid}");
1244
1245 let url = registry
1246 .url_for(
1247 "get_object",
1248 &[("id", "550e8400-e29b-41d4-a716-446655440000")],
1249 &[],
1250 )
1251 .unwrap();
1252 assert_eq!(url, "/objects/550e8400-e29b-41d4-a716-446655440000");
1253 }
1254
1255 #[test]
1256 fn url_for_with_query_params() {
1257 let mut registry = UrlRegistry::new();
1258 registry.register("search", "/search");
1259
1260 let url = registry
1261 .url_for("search", &[], &[("q", "hello"), ("page", "1")])
1262 .unwrap();
1263 assert_eq!(url, "/search?q=hello&page=1");
1264 }
1265
1266 #[test]
1267 fn url_for_encodes_query_params() {
1268 let mut registry = UrlRegistry::new();
1269 registry.register("search", "/search");
1270
1271 let url = registry
1272 .url_for("search", &[], &[("q", "hello world"), ("filter", "a&b=c")])
1273 .unwrap();
1274 assert_eq!(url, "/search?q=hello%20world&filter=a%26b%3Dc");
1275 }
1276
1277 #[test]
1278 fn url_for_encodes_path_params() {
1279 let mut registry = UrlRegistry::new();
1280 registry.register("get_file", "/files/{name}");
1281
1282 let url = registry
1283 .url_for("get_file", &[("name", "my file.txt")], &[])
1284 .unwrap();
1285 assert_eq!(url, "/files/my%20file.txt");
1286 }
1287
1288 #[test]
1289 fn url_for_with_root_path() {
1290 let mut registry = UrlRegistry::with_root_path("/api/v1");
1291 registry.register("get_user", "/users/{id}");
1292
1293 let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1294 assert_eq!(url, "/api/v1/users/42");
1295 }
1296
1297 #[test]
1298 fn url_for_route_not_found() {
1299 let registry = UrlRegistry::new();
1300 let result = registry.url_for("nonexistent", &[], &[]);
1301 assert!(matches!(result, Err(UrlError::RouteNotFound { name }) if name == "nonexistent"));
1302 }
1303
1304 #[test]
1305 fn url_for_missing_param() {
1306 let mut registry = UrlRegistry::new();
1307 registry.register("get_user", "/users/{id}");
1308
1309 let result = registry.url_for("get_user", &[], &[]);
1310 assert!(matches!(
1311 result,
1312 Err(UrlError::MissingParam { name, param }) if name == "get_user" && param == "id"
1313 ));
1314 }
1315
1316 #[test]
1317 fn url_for_with_path_converter() {
1318 let mut registry = UrlRegistry::new();
1319 registry.register("get_file", "/files/{path:path}");
1320
1321 let url = registry
1322 .url_for("get_file", &[("path", "docs/images/logo.png")], &[])
1323 .unwrap();
1324 assert_eq!(url, "/files/docs/images/logo.png");
1326 }
1327
1328 #[test]
1329 fn url_encode_basic() {
1330 assert_eq!(url_encode("hello"), "hello");
1331 assert_eq!(url_encode("hello world"), "hello%20world");
1332 assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
1333 assert_eq!(url_encode("100%"), "100%25");
1334 }
1335
1336 #[test]
1337 fn url_encode_unicode() {
1338 assert_eq!(url_encode("日本"), "%E6%97%A5%E6%9C%AC");
1339 assert_eq!(url_encode("café"), "caf%C3%A9");
1340 }
1341
1342 #[test]
1343 fn url_encode_path_segment_preserves_slashes() {
1344 assert_eq!(url_encode("a/b/c"), "a%2Fb%2Fc");
1346 assert_eq!(url_encode_path_segment("a/b/c"), "a/b/c");
1348 assert_eq!(url_encode_path_segment("a b/c"), "a%20b/c");
1350 assert_eq!(url_encode_path_segment("a&b/c"), "a%26b/c");
1351 }
1352
1353 #[test]
1354 fn url_decode_basic() {
1355 assert_eq!(url_decode("hello"), Some("hello".to_string()));
1356 assert_eq!(url_decode("hello%20world"), Some("hello world".to_string()));
1357 assert_eq!(url_decode("a%26b%3Dc"), Some("a&b=c".to_string()));
1358 }
1359
1360 #[test]
1361 fn url_decode_plus_as_space() {
1362 assert_eq!(url_decode("hello+world"), Some("hello world".to_string()));
1363 }
1364
1365 #[test]
1366 fn url_decode_invalid() {
1367 assert_eq!(url_decode("hello%2"), None);
1369 assert_eq!(url_decode("hello%"), None);
1370 assert_eq!(url_decode("hello%GG"), None);
1372 }
1373
1374 #[test]
1375 fn url_error_display() {
1376 let err = UrlError::RouteNotFound {
1377 name: "test".to_string(),
1378 };
1379 assert_eq!(format!("{}", err), "route 'test' not found");
1380
1381 let err = UrlError::MissingParam {
1382 name: "get_user".to_string(),
1383 param: "id".to_string(),
1384 };
1385 assert_eq!(
1386 format!("{}", err),
1387 "route 'get_user' requires parameter 'id'"
1388 );
1389
1390 let err = UrlError::InvalidParam {
1391 name: "get_item".to_string(),
1392 param: "id".to_string(),
1393 value: "abc".to_string(),
1394 };
1395 assert_eq!(
1396 format!("{}", err),
1397 "route 'get_item' parameter 'id': invalid value 'abc'"
1398 );
1399 }
1400
1401 #[test]
1402 fn url_registry_route_names_iterator() {
1403 let mut registry = UrlRegistry::new();
1404 registry.register("a", "/a");
1405 registry.register("b", "/b");
1406 registry.register("c", "/c");
1407
1408 let names: Vec<_> = registry.route_names().collect();
1409 assert_eq!(names.len(), 3);
1410 assert!(names.contains(&"a"));
1411 assert!(names.contains(&"b"));
1412 assert!(names.contains(&"c"));
1413 }
1414
1415 #[test]
1416 fn url_registry_set_root_path() {
1417 let mut registry = UrlRegistry::new();
1418 registry.register("home", "/");
1419
1420 let url1 = registry.url_for("home", &[], &[]).unwrap();
1421 assert_eq!(url1, "/");
1422
1423 registry.set_root_path("/api");
1424 let url2 = registry.url_for("home", &[], &[]).unwrap();
1425 assert_eq!(url2, "/api/");
1426 }
1427
1428 #[test]
1433 fn trailing_slash_strict_mode_matches_both_due_to_segment_parsing() {
1434 let mut table = RouteTable::new();
1438 table.add(Method::Get, "/users", "users");
1439
1440 assert!(matches!(
1441 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Strict),
1442 RouteLookup::Match {
1443 route: &"users",
1444 ..
1445 }
1446 ));
1447
1448 assert!(matches!(
1450 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Strict),
1451 RouteLookup::Match {
1452 route: &"users",
1453 ..
1454 }
1455 ));
1456 }
1457
1458 #[test]
1459 fn trailing_slash_redirect_mode_exact_match_no_redirect() {
1460 let mut table = RouteTable::new();
1461 table.add(Method::Get, "/users", "users");
1462
1463 assert!(matches!(
1465 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Redirect),
1466 RouteLookup::Match {
1467 route: &"users",
1468 ..
1469 }
1470 ));
1471
1472 assert!(matches!(
1475 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Redirect),
1476 RouteLookup::Match {
1477 route: &"users",
1478 ..
1479 }
1480 ));
1481 }
1482
1483 #[test]
1484 fn trailing_slash_match_both_mode() {
1485 let mut table = RouteTable::new();
1486 table.add(Method::Get, "/users", "users");
1487
1488 assert!(matches!(
1490 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::MatchBoth),
1491 RouteLookup::Match {
1492 route: &"users",
1493 ..
1494 }
1495 ));
1496 assert!(matches!(
1497 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::MatchBoth),
1498 RouteLookup::Match {
1499 route: &"users",
1500 ..
1501 }
1502 ));
1503 }
1504
1505 #[test]
1506 fn trailing_slash_root_path_not_redirected() {
1507 let mut table = RouteTable::new();
1508 table.add(Method::Get, "/", "root");
1509
1510 assert!(matches!(
1511 table.lookup_with_trailing_slash("/", Method::Get, TrailingSlashMode::Redirect),
1512 RouteLookup::Match { route: &"root", .. }
1513 ));
1514 }
1515
1516 #[test]
1517 fn trailing_slash_with_path_params() {
1518 let mut table = RouteTable::new();
1519 table.add(Method::Get, "/users/{id}", "get_user");
1520
1521 match table.lookup_with_trailing_slash(
1523 "/users/42/",
1524 Method::Get,
1525 TrailingSlashMode::MatchBoth,
1526 ) {
1527 RouteLookup::Match { params, .. } => {
1528 assert_eq!(params.len(), 1);
1529 assert_eq!(params[0], ("id".to_string(), "42".to_string()));
1530 }
1531 other => panic!("expected Match, got {:?}", other),
1532 }
1533 }
1534
1535 #[test]
1536 fn trailing_slash_not_found_stays_not_found() {
1537 let mut table = RouteTable::new();
1538 table.add(Method::Get, "/users", "users");
1539
1540 assert!(matches!(
1542 table.lookup_with_trailing_slash(
1543 "/nonexistent",
1544 Method::Get,
1545 TrailingSlashMode::Redirect
1546 ),
1547 RouteLookup::NotFound
1548 ));
1549 assert!(matches!(
1550 table.lookup_with_trailing_slash(
1551 "/nonexistent/",
1552 Method::Get,
1553 TrailingSlashMode::Redirect
1554 ),
1555 RouteLookup::NotFound
1556 ));
1557 }
1558
1559 #[test]
1560 fn trailing_slash_mode_default_is_strict() {
1561 assert_eq!(TrailingSlashMode::default(), TrailingSlashMode::Strict);
1562 }
1563
1564 #[test]
1565 fn app_config_trailing_slash_mode() {
1566 use crate::app::AppConfig;
1567
1568 let config = AppConfig::new();
1569 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Strict);
1570
1571 let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::Redirect);
1572 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Redirect);
1573
1574 let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::MatchBoth);
1575 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::MatchBoth);
1576 }
1577
1578 #[test]
1581 fn converter_str_rejects_dot_dot_traversal() {
1582 assert!(!Converter::Str.matches(".."));
1583 assert!(!Converter::Str.matches("."));
1584 assert!(Converter::Str.matches("users"));
1586 assert!(Converter::Str.matches("file.txt"));
1587 assert!(Converter::Str.matches("my..name"));
1588 }
1589
1590 #[test]
1591 fn converter_path_rejects_traversal_components() {
1592 assert!(!Converter::Path.matches("../etc/passwd"));
1593 assert!(!Converter::Path.matches("foo/../../bar"));
1594 assert!(!Converter::Path.matches("./hidden"));
1595 assert!(!Converter::Path.matches(".."));
1596 assert!(Converter::Path.matches("a/b/c.txt"));
1598 assert!(Converter::Path.matches("docs/readme.md"));
1599 }
1600
1601 #[test]
1602 fn converter_float_rejects_nan_and_infinity() {
1603 assert!(!Converter::Float.matches("NaN"));
1604 assert!(!Converter::Float.matches("inf"));
1605 assert!(!Converter::Float.matches("-inf"));
1606 assert!(!Converter::Float.matches("infinity"));
1607 assert!(!Converter::Float.matches("-infinity"));
1608 assert!(Converter::Float.matches("3.14"));
1610 assert!(Converter::Float.matches("-1.5"));
1611 assert!(Converter::Float.matches("1e10"));
1612 assert!(Converter::Float.matches("42"));
1613 }
1614
1615 #[test]
1616 fn route_table_rejects_traversal_in_str_param() {
1617 let mut table = RouteTable::new();
1618 table.add(Method::Get, "/files/{name}", "handler");
1619
1620 assert!(matches!(
1622 table.lookup("/files/readme.txt", Method::Get),
1623 RouteLookup::Match { .. }
1624 ));
1625
1626 assert!(matches!(
1628 table.lookup("/files/..", Method::Get),
1629 RouteLookup::NotFound
1630 ));
1631 }
1632
1633 #[test]
1634 fn route_table_rejects_traversal_in_path_param() {
1635 let mut table = RouteTable::new();
1636 table.add(Method::Get, "/files/{filepath:path}", "handler");
1637
1638 if let RouteLookup::Match { params, .. } =
1640 table.lookup("/files/docs/readme.md", Method::Get)
1641 {
1642 assert_eq!(params[0].1, "docs/readme.md");
1643 } else {
1644 panic!("Expected match for normal path");
1645 }
1646
1647 assert!(matches!(
1649 table.lookup("/files/../etc/passwd", Method::Get),
1650 RouteLookup::NotFound
1651 ));
1652 assert!(matches!(
1653 table.lookup("/files/a/../../etc/shadow", Method::Get),
1654 RouteLookup::NotFound
1655 ));
1656 }
1657
1658 #[test]
1659 fn path_has_traversal_helper() {
1660 assert!(path_has_traversal(".."));
1661 assert!(path_has_traversal("."));
1662 assert!(path_has_traversal("../foo"));
1663 assert!(path_has_traversal("foo/.."));
1664 assert!(path_has_traversal("foo/../bar"));
1665 assert!(path_has_traversal("./bar"));
1666 assert!(!path_has_traversal("foo/bar"));
1667 assert!(!path_has_traversal("foo.bar"));
1668 assert!(!path_has_traversal("a..b"));
1669 }
1670}