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]
81 pub fn matches(&self, value: &str) -> bool {
82 match self {
83 Self::Str => true,
84 Self::Int => value.parse::<i64>().is_ok(),
85 Self::Float => value.parse::<f64>().is_ok(),
86 Self::Uuid => is_uuid(value),
87 Self::Path => true,
88 }
89 }
90
91 #[must_use]
93 pub fn parse(s: &str) -> Self {
94 match s {
95 "int" => Self::Int,
96 "float" => Self::Float,
97 "uuid" => Self::Uuid,
98 "path" => Self::Path,
99 _ => Self::Str,
100 }
101 }
102}
103
104fn is_uuid(s: &str) -> bool {
105 if s.len() != 36 {
107 return false;
108 }
109 let parts: Vec<_> = s.split('-').collect();
110 if parts.len() != 5 {
111 return false;
112 }
113 parts[0].len() == 8
114 && parts[1].len() == 4
115 && parts[2].len() == 4
116 && parts[3].len() == 4
117 && parts[4].len() == 12
118 && parts
119 .iter()
120 .all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
121}
122
123#[derive(Debug, Clone)]
125pub struct ParamInfo {
126 pub name: String,
128 pub converter: Converter,
130}
131
132#[derive(Debug, Clone)]
134pub enum PathSegment {
135 Static(String),
137 Param(ParamInfo),
139}
140
141#[derive(Debug, Clone)]
143pub struct RoutePattern {
144 pub pattern: String,
146 pub segments: Vec<PathSegment>,
148 pub has_path_converter: bool,
150}
151
152impl RoutePattern {
153 #[must_use]
161 pub fn parse(pattern: &str) -> Self {
162 let segments = parse_path_segments(pattern);
163 let has_path_converter = matches!(
164 segments.last(),
165 Some(PathSegment::Param(ParamInfo {
166 converter: Converter::Path,
167 ..
168 }))
169 );
170
171 Self {
172 pattern: pattern.to_string(),
173 segments,
174 has_path_converter,
175 }
176 }
177
178 #[must_use]
183 pub fn match_path<'a>(&self, path: &'a str) -> Option<Vec<(String, &'a str)>> {
184 let path_ranges = segment_ranges(path);
185 let mut path_segments: Vec<&'a str> = Vec::with_capacity(path_ranges.len());
186 for (start, end) in &path_ranges {
187 path_segments.push(&path[*start..*end]);
188 }
189
190 let mut params = Vec::new();
191 let mut path_idx = 0;
192 let last_end = path_ranges.last().map_or(0, |(_, end)| *end);
193
194 for segment in &self.segments {
195 match segment {
196 PathSegment::Static(expected) => {
197 if path_idx >= path_segments.len() || path_segments[path_idx] != expected {
198 return None;
199 }
200 path_idx += 1;
201 }
202 PathSegment::Param(info) => {
203 if path_idx >= path_segments.len() {
204 return None;
205 }
206
207 if info.converter == Converter::Path {
208 let start = path_ranges[path_idx].0;
210 let value = &path[start..last_end];
211 params.push((info.name.clone(), value));
212 path_idx = path_segments.len();
214 } else {
215 let value = path_segments[path_idx];
216 if !info.converter.matches(value) {
217 return None;
218 }
219 params.push((info.name.clone(), value));
220 path_idx += 1;
221 }
222 }
223 }
224 }
225
226 if path_idx != path_segments.len() && !self.has_path_converter {
228 return None;
229 }
230
231 Some(params)
232 }
233
234 #[must_use]
238 pub fn could_match(&self, path: &str) -> bool {
239 self.match_path(path).is_some()
240 }
241}
242
243fn parse_path_segments(path: &str) -> Vec<PathSegment> {
244 path.split('/')
245 .filter(|s| !s.is_empty())
246 .map(|s| {
247 if s.starts_with('{') && s.ends_with('}') {
248 let inner = &s[1..s.len() - 1];
249 let (name, converter) = if let Some(pos) = inner.find(':') {
250 let conv = Converter::parse(&inner[pos + 1..]);
251 (inner[..pos].to_string(), conv)
252 } else {
253 (inner.to_string(), Converter::Str)
254 };
255 PathSegment::Param(ParamInfo { name, converter })
256 } else {
257 PathSegment::Static(s.to_string())
258 }
259 })
260 .collect()
261}
262
263fn segment_ranges(path: &str) -> Vec<(usize, usize)> {
264 let bytes = path.as_bytes();
265 let mut ranges = Vec::new();
266 let mut idx = 0;
267 while idx < bytes.len() {
268 while idx < bytes.len() && bytes[idx] == b'/' {
270 idx += 1;
271 }
272 if idx >= bytes.len() {
273 break;
274 }
275 let start = idx;
276 while idx < bytes.len() && bytes[idx] != b'/' {
278 idx += 1;
279 }
280 ranges.push((start, idx));
281 }
282 ranges
283}
284
285#[derive(Debug)]
287pub enum RouteLookup<'a, T> {
288 Match {
290 route: &'a T,
292 params: Vec<(String, String)>,
294 },
295 MethodNotAllowed {
297 allowed: Vec<Method>,
299 },
300 Redirect {
304 target: String,
306 },
307 NotFound,
309}
310
311pub struct RouteTable<T> {
316 routes: Vec<(Method, RoutePattern, T)>,
317}
318
319impl<T> RouteTable<T> {
320 #[must_use]
322 pub fn new() -> Self {
323 Self { routes: Vec::new() }
324 }
325
326 pub fn add(&mut self, method: Method, pattern: &str, data: T) {
328 let parsed = RoutePattern::parse(pattern);
329 self.routes.push((method, parsed, data));
330 }
331
332 #[must_use]
334 pub fn lookup(&self, path: &str, method: Method) -> RouteLookup<'_, T> {
335 for (route_method, pattern, data) in &self.routes {
337 if let Some(params) = pattern.match_path(path) {
338 let owned_params: Vec<(String, String)> = params
340 .into_iter()
341 .map(|(name, value)| (name, value.to_string()))
342 .collect();
343
344 if *route_method == method {
345 return RouteLookup::Match {
346 route: data,
347 params: owned_params,
348 };
349 }
350 if method == Method::Head && *route_method == Method::Get {
352 return RouteLookup::Match {
353 route: data,
354 params: owned_params,
355 };
356 }
357 }
358 }
359
360 let mut allowed_methods: Vec<Method> = Vec::new();
362 for (route_method, pattern, _) in &self.routes {
363 if pattern.could_match(path) && !allowed_methods.contains(route_method) {
364 allowed_methods.push(*route_method);
365 }
366 }
367
368 if !allowed_methods.is_empty() {
369 if allowed_methods.contains(&Method::Get) && !allowed_methods.contains(&Method::Head) {
371 allowed_methods.push(Method::Head);
372 }
373 allowed_methods.sort_by_key(|m| method_order(*m));
375 return RouteLookup::MethodNotAllowed {
376 allowed: allowed_methods,
377 };
378 }
379
380 RouteLookup::NotFound
381 }
382
383 #[must_use]
391 pub fn lookup_with_trailing_slash(
392 &self,
393 path: &str,
394 method: Method,
395 mode: TrailingSlashMode,
396 ) -> RouteLookup<'_, T> {
397 let result = self.lookup(path, method);
399 if !matches!(result, RouteLookup::NotFound) {
400 return result;
401 }
402
403 if mode == TrailingSlashMode::Strict {
405 return result;
406 }
407
408 let has_trailing_slash = path.len() > 1 && path.ends_with('/');
410 let alt_path = if has_trailing_slash {
411 &path[..path.len() - 1]
413 } else {
414 return self.lookup_with_trailing_slash_add(path, method, mode);
416 };
417
418 let alt_result = self.lookup(alt_path, method);
419 match (&alt_result, mode) {
420 (RouteLookup::Match { .. }, TrailingSlashMode::Redirect) => {
421 RouteLookup::Redirect {
423 target: alt_path.to_string(),
424 }
425 }
426 (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
427 RouteLookup::Match {
429 route,
430 params: params.clone(),
431 }
432 }
433 (RouteLookup::MethodNotAllowed { allowed: _ }, TrailingSlashMode::Redirect) => {
434 RouteLookup::Redirect {
436 target: alt_path.to_string(),
437 }
438 }
439 (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
440 RouteLookup::MethodNotAllowed {
442 allowed: allowed.clone(),
443 }
444 }
445 _ => result, }
447 }
448
449 fn lookup_with_trailing_slash_add(
451 &self,
452 path: &str,
453 method: Method,
454 mode: TrailingSlashMode,
455 ) -> RouteLookup<'_, T> {
456 let with_slash = format!("{}/", path);
458 let alt_result = self.lookup(&with_slash, method);
459
460 match (&alt_result, mode) {
461 (RouteLookup::Match { .. }, TrailingSlashMode::RedirectWithSlash) => {
462 RouteLookup::Redirect { target: with_slash }
464 }
465 (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
466 RouteLookup::Match {
468 route,
469 params: params.clone(),
470 }
471 }
472 (
473 RouteLookup::MethodNotAllowed { allowed: _ },
474 TrailingSlashMode::RedirectWithSlash,
475 ) => {
476 RouteLookup::Redirect { target: with_slash }
478 }
479 (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
480 RouteLookup::MethodNotAllowed {
482 allowed: allowed.clone(),
483 }
484 }
485 _ => RouteLookup::NotFound,
486 }
487 }
488
489 #[must_use]
491 pub fn len(&self) -> usize {
492 self.routes.len()
493 }
494
495 #[must_use]
497 pub fn is_empty(&self) -> bool {
498 self.routes.is_empty()
499 }
500}
501
502impl<T> Default for RouteTable<T> {
503 fn default() -> Self {
504 Self::new()
505 }
506}
507
508#[must_use]
513pub fn method_order(method: Method) -> u8 {
514 match method {
515 Method::Get => 0,
516 Method::Head => 1,
517 Method::Post => 2,
518 Method::Put => 3,
519 Method::Delete => 4,
520 Method::Patch => 5,
521 Method::Options => 6,
522 Method::Trace => 7,
523 }
524}
525
526#[must_use]
528pub fn format_allow_header(methods: &[Method]) -> String {
529 methods
530 .iter()
531 .map(|m| m.as_str())
532 .collect::<Vec<_>>()
533 .join(", ")
534}
535
536use std::collections::HashMap;
549
550#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum UrlError {
553 RouteNotFound { name: String },
555 MissingParam { name: String, param: String },
557 InvalidParam {
559 name: String,
560 param: String,
561 value: String,
562 },
563}
564
565impl std::fmt::Display for UrlError {
566 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567 match self {
568 Self::RouteNotFound { name } => {
569 write!(f, "route '{}' not found", name)
570 }
571 Self::MissingParam { name, param } => {
572 write!(f, "route '{}' requires parameter '{}'", name, param)
573 }
574 Self::InvalidParam { name, param, value } => {
575 write!(
576 f,
577 "route '{}' parameter '{}': invalid value '{}'",
578 name, param, value
579 )
580 }
581 }
582 }
583}
584
585impl std::error::Error for UrlError {}
586
587#[derive(Debug, Clone, Default)]
607pub struct UrlRegistry {
608 routes: HashMap<String, RoutePattern>,
610 root_path: String,
612}
613
614impl UrlRegistry {
615 #[must_use]
617 pub fn new() -> Self {
618 Self {
619 routes: HashMap::new(),
620 root_path: String::new(),
621 }
622 }
623
624 #[must_use]
638 pub fn with_root_path(root_path: impl Into<String>) -> Self {
639 let mut path = root_path.into();
640 while path.ends_with('/') {
642 path.pop();
643 }
644 Self {
645 routes: HashMap::new(),
646 root_path: path,
647 }
648 }
649
650 pub fn set_root_path(&mut self, root_path: impl Into<String>) {
652 let mut path = root_path.into();
653 while path.ends_with('/') {
654 path.pop();
655 }
656 self.root_path = path;
657 }
658
659 #[must_use]
661 pub fn root_path(&self) -> &str {
662 &self.root_path
663 }
664
665 pub fn register(&mut self, name: impl Into<String>, pattern: &str) {
672 let name = name.into();
673 let parsed = RoutePattern::parse(pattern);
674 self.routes.insert(name, parsed);
675 }
676
677 #[must_use]
679 pub fn has_route(&self, name: &str) -> bool {
680 self.routes.contains_key(name)
681 }
682
683 #[must_use]
685 pub fn get_pattern(&self, name: &str) -> Option<&str> {
686 self.routes.get(name).map(|p| p.pattern.as_str())
687 }
688
689 pub fn url_for(
715 &self,
716 name: &str,
717 params: &[(&str, &str)],
718 query: &[(&str, &str)],
719 ) -> Result<String, UrlError> {
720 let pattern = self
721 .routes
722 .get(name)
723 .ok_or_else(|| UrlError::RouteNotFound {
724 name: name.to_string(),
725 })?;
726
727 let param_map: HashMap<&str, &str> = params.iter().copied().collect();
729
730 let mut path = String::new();
732 if !self.root_path.is_empty() {
733 path.push_str(&self.root_path);
734 }
735
736 let has_segments = !pattern.segments.is_empty();
738
739 for segment in &pattern.segments {
740 path.push('/');
741 match segment {
742 PathSegment::Static(s) => {
743 path.push_str(s);
744 }
745 PathSegment::Param(info) => {
746 let value = *param_map.get(info.name.as_str()).ok_or_else(|| {
747 UrlError::MissingParam {
748 name: name.to_string(),
749 param: info.name.clone(),
750 }
751 })?;
752
753 if !info.converter.matches(value) {
755 return Err(UrlError::InvalidParam {
756 name: name.to_string(),
757 param: info.name.clone(),
758 value: value.to_string(),
759 });
760 }
761
762 if info.converter == Converter::Path {
764 path.push_str(value);
765 } else {
766 path.push_str(&url_encode_path_segment(value));
767 }
768 }
769 }
770 }
771
772 if path.is_empty() || (!has_segments && !self.root_path.is_empty()) {
775 path.push('/');
776 }
777
778 if !query.is_empty() {
780 path.push('?');
781 for (i, (key, value)) in query.iter().enumerate() {
782 if i > 0 {
783 path.push('&');
784 }
785 path.push_str(&url_encode(key));
786 path.push('=');
787 path.push_str(&url_encode(value));
788 }
789 }
790
791 Ok(path)
792 }
793
794 #[must_use]
796 pub fn len(&self) -> usize {
797 self.routes.len()
798 }
799
800 #[must_use]
802 pub fn is_empty(&self) -> bool {
803 self.routes.is_empty()
804 }
805
806 pub fn route_names(&self) -> impl Iterator<Item = &str> {
808 self.routes.keys().map(String::as_str)
809 }
810}
811
812#[must_use]
816pub fn url_encode(s: &str) -> String {
817 let mut result = String::with_capacity(s.len() * 3);
818 for byte in s.bytes() {
819 match byte {
820 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
822 result.push(byte as char);
823 }
824 _ => {
826 result.push('%');
827 result.push(
828 char::from_digit(u32::from(byte >> 4), 16)
829 .unwrap()
830 .to_ascii_uppercase(),
831 );
832 result.push(
833 char::from_digit(u32::from(byte & 0xF), 16)
834 .unwrap()
835 .to_ascii_uppercase(),
836 );
837 }
838 }
839 }
840 result
841}
842
843#[must_use]
847pub fn url_encode_path_segment(s: &str) -> String {
848 let mut result = String::with_capacity(s.len() * 3);
849 for byte in s.bytes() {
850 match byte {
851 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
853 result.push(byte as char);
854 }
855 _ => {
857 result.push('%');
858 result.push(
859 char::from_digit(u32::from(byte >> 4), 16)
860 .unwrap()
861 .to_ascii_uppercase(),
862 );
863 result.push(
864 char::from_digit(u32::from(byte & 0xF), 16)
865 .unwrap()
866 .to_ascii_uppercase(),
867 );
868 }
869 }
870 }
871 result
872}
873
874#[must_use]
880pub fn url_decode(s: &str) -> Option<String> {
881 let mut result = Vec::with_capacity(s.len());
882 let mut bytes = s.bytes();
883
884 while let Some(byte) = bytes.next() {
885 if byte == b'%' {
886 let hi = bytes.next()?;
887 let lo = bytes.next()?;
888 let hi = char::from(hi).to_digit(16)?;
889 let lo = char::from(lo).to_digit(16)?;
890 result.push((hi * 16 + lo) as u8);
891 } else if byte == b'+' {
892 result.push(b' ');
894 } else {
895 result.push(byte);
896 }
897 }
898
899 String::from_utf8(result).ok()
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905
906 #[test]
907 fn converter_str_matches_anything() {
908 assert!(Converter::Str.matches("hello"));
909 assert!(Converter::Str.matches("123"));
910 assert!(Converter::Str.matches(""));
911 }
912
913 #[test]
914 fn converter_int_matches_integers() {
915 assert!(Converter::Int.matches("123"));
916 assert!(Converter::Int.matches("-456"));
917 assert!(Converter::Int.matches("0"));
918 assert!(!Converter::Int.matches("12.34"));
919 assert!(!Converter::Int.matches("abc"));
920 assert!(!Converter::Int.matches(""));
921 }
922
923 #[test]
924 fn converter_float_matches_floats() {
925 assert!(Converter::Float.matches("3.14"));
926 assert!(Converter::Float.matches("42"));
927 assert!(Converter::Float.matches("-1.5"));
928 assert!(Converter::Float.matches("1e10"));
929 assert!(!Converter::Float.matches("abc"));
930 }
931
932 #[test]
933 fn converter_uuid_matches_uuids() {
934 assert!(Converter::Uuid.matches("550e8400-e29b-41d4-a716-446655440000"));
935 assert!(Converter::Uuid.matches("550E8400-E29B-41D4-A716-446655440000"));
936 assert!(!Converter::Uuid.matches("not-a-uuid"));
937 assert!(!Converter::Uuid.matches("550e8400e29b41d4a716446655440000")); }
939
940 #[test]
941 fn parse_static_path() {
942 let pattern = RoutePattern::parse("/users");
943 assert_eq!(pattern.segments.len(), 1);
944 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
945 }
946
947 #[test]
948 fn parse_path_with_param() {
949 let pattern = RoutePattern::parse("/users/{id}");
950 assert_eq!(pattern.segments.len(), 2);
951 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
952 assert!(
953 matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Str)
954 );
955 }
956
957 #[test]
958 fn parse_path_with_typed_param() {
959 let pattern = RoutePattern::parse("/items/{id:int}");
960 assert_eq!(pattern.segments.len(), 2);
961 assert!(
962 matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Int)
963 );
964 }
965
966 #[test]
967 fn parse_path_with_path_converter() {
968 let pattern = RoutePattern::parse("/files/{path:path}");
969 assert!(pattern.has_path_converter);
970 }
971
972 #[test]
973 fn match_static_path() {
974 let pattern = RoutePattern::parse("/users");
975 assert!(pattern.match_path("/users").is_some());
976 assert!(pattern.match_path("/items").is_none());
977 }
978
979 #[test]
980 fn match_path_extracts_params() {
981 let pattern = RoutePattern::parse("/users/{id}");
982 let params = pattern.match_path("/users/42").unwrap();
983 assert_eq!(params.len(), 1);
984 assert_eq!(params[0].0, "id");
985 assert_eq!(params[0].1, "42");
986 }
987
988 #[test]
989 fn match_path_validates_int_converter() {
990 let pattern = RoutePattern::parse("/items/{id:int}");
991 assert!(pattern.match_path("/items/123").is_some());
992 assert!(pattern.match_path("/items/abc").is_none());
993 }
994
995 #[test]
996 fn match_path_validates_uuid_converter() {
997 let pattern = RoutePattern::parse("/objects/{id:uuid}");
998 assert!(
999 pattern
1000 .match_path("/objects/550e8400-e29b-41d4-a716-446655440000")
1001 .is_some()
1002 );
1003 assert!(pattern.match_path("/objects/not-a-uuid").is_none());
1004 }
1005
1006 #[test]
1007 fn match_path_converter_captures_slashes() {
1008 let pattern = RoutePattern::parse("/files/{path:path}");
1009 let params = pattern.match_path("/files/a/b/c.txt").unwrap();
1010 assert_eq!(params[0].0, "path");
1011 assert_eq!(params[0].1, "a/b/c.txt");
1012 }
1013
1014 #[test]
1015 fn match_multiple_params() {
1016 let pattern = RoutePattern::parse("/users/{user_id}/posts/{post_id}");
1017 let params = pattern.match_path("/users/42/posts/99").unwrap();
1018 assert_eq!(params.len(), 2);
1019 assert_eq!(params[0].0, "user_id");
1020 assert_eq!(params[0].1, "42");
1021 assert_eq!(params[1].0, "post_id");
1022 assert_eq!(params[1].1, "99");
1023 }
1024
1025 #[test]
1026 fn route_table_lookup_match() {
1027 let mut table: RouteTable<&str> = RouteTable::new();
1028 table.add(Method::Get, "/users/{id}", "get_user");
1029 table.add(Method::Post, "/users", "create_user");
1030
1031 match table.lookup("/users/42", Method::Get) {
1032 RouteLookup::Match { route, params } => {
1033 assert_eq!(*route, "get_user");
1034 assert_eq!(params[0].0, "id");
1035 assert_eq!(params[0].1, "42");
1036 }
1037 _ => panic!("Expected match"),
1038 }
1039 }
1040
1041 #[test]
1042 fn route_table_lookup_method_not_allowed() {
1043 let mut table: RouteTable<&str> = RouteTable::new();
1044 table.add(Method::Get, "/users", "get_users");
1045 table.add(Method::Post, "/users", "create_user");
1046
1047 match table.lookup("/users", Method::Delete) {
1048 RouteLookup::MethodNotAllowed { allowed } => {
1049 assert!(allowed.contains(&Method::Get));
1050 assert!(allowed.contains(&Method::Head));
1051 assert!(allowed.contains(&Method::Post));
1052 }
1053 _ => panic!("Expected MethodNotAllowed"),
1054 }
1055 }
1056
1057 #[test]
1058 fn route_table_lookup_not_found() {
1059 let mut table: RouteTable<&str> = RouteTable::new();
1060 table.add(Method::Get, "/users", "get_users");
1061
1062 assert!(matches!(
1063 table.lookup("/items", Method::Get),
1064 RouteLookup::NotFound
1065 ));
1066 }
1067
1068 #[test]
1069 fn route_table_head_matches_get() {
1070 let mut table: RouteTable<&str> = RouteTable::new();
1071 table.add(Method::Get, "/users", "get_users");
1072
1073 match table.lookup("/users", Method::Head) {
1074 RouteLookup::Match { route, .. } => {
1075 assert_eq!(*route, "get_users");
1076 }
1077 _ => panic!("Expected match for HEAD on GET route"),
1078 }
1079 }
1080
1081 #[test]
1082 fn format_allow_header_formats_methods() {
1083 let methods = vec![Method::Get, Method::Head, Method::Post];
1084 assert_eq!(format_allow_header(&methods), "GET, HEAD, POST");
1085 }
1086
1087 #[test]
1088 fn options_request_returns_method_not_allowed_with_allowed_methods() {
1089 let mut table: RouteTable<&str> = RouteTable::new();
1090 table.add(Method::Get, "/users", "get_users");
1091 table.add(Method::Post, "/users", "create_user");
1092
1093 match table.lookup("/users", Method::Options) {
1096 RouteLookup::MethodNotAllowed { allowed } => {
1097 assert!(allowed.contains(&Method::Get));
1098 assert!(allowed.contains(&Method::Head));
1099 assert!(allowed.contains(&Method::Post));
1100 }
1101 _ => panic!("Expected MethodNotAllowed for OPTIONS request"),
1102 }
1103 }
1104
1105 #[test]
1106 fn options_request_on_nonexistent_path_returns_not_found() {
1107 let mut table: RouteTable<&str> = RouteTable::new();
1108 table.add(Method::Get, "/users", "get_users");
1109
1110 match table.lookup("/items", Method::Options) {
1111 RouteLookup::NotFound => {}
1112 _ => panic!("Expected NotFound for OPTIONS on non-existent path"),
1113 }
1114 }
1115
1116 #[test]
1117 fn explicit_options_handler_matches() {
1118 let mut table: RouteTable<&str> = RouteTable::new();
1119 table.add(Method::Get, "/api/resource", "get_resource");
1120 table.add(Method::Options, "/api/resource", "options_resource");
1121
1122 match table.lookup("/api/resource", Method::Options) {
1123 RouteLookup::Match { route, .. } => {
1124 assert_eq!(*route, "options_resource");
1125 }
1126 _ => panic!("Expected match for explicit OPTIONS handler"),
1127 }
1128 }
1129
1130 #[test]
1131 fn method_order_returns_expected_ordering() {
1132 assert!(method_order(Method::Get) < method_order(Method::Post));
1133 assert!(method_order(Method::Head) < method_order(Method::Post));
1134 assert!(method_order(Method::Options) < method_order(Method::Trace));
1135 assert!(method_order(Method::Delete) < method_order(Method::Options));
1136 }
1137
1138 #[test]
1143 fn url_registry_new() {
1144 let registry = UrlRegistry::new();
1145 assert!(registry.is_empty());
1146 assert_eq!(registry.len(), 0);
1147 assert_eq!(registry.root_path(), "");
1148 }
1149
1150 #[test]
1151 fn url_registry_with_root_path() {
1152 let registry = UrlRegistry::with_root_path("/api/v1");
1153 assert_eq!(registry.root_path(), "/api/v1");
1154 }
1155
1156 #[test]
1157 fn url_registry_with_root_path_normalizes_trailing_slash() {
1158 let registry = UrlRegistry::with_root_path("/api/v1/");
1159 assert_eq!(registry.root_path(), "/api/v1");
1160
1161 let registry2 = UrlRegistry::with_root_path("/api///");
1162 assert_eq!(registry2.root_path(), "/api");
1163 }
1164
1165 #[test]
1166 fn url_registry_register_and_lookup() {
1167 let mut registry = UrlRegistry::new();
1168 registry.register("get_user", "/users/{id}");
1169
1170 assert!(registry.has_route("get_user"));
1171 assert!(!registry.has_route("nonexistent"));
1172 assert_eq!(registry.get_pattern("get_user"), Some("/users/{id}"));
1173 assert_eq!(registry.len(), 1);
1174 }
1175
1176 #[test]
1177 fn url_for_static_route() {
1178 let mut registry = UrlRegistry::new();
1179 registry.register("home", "/");
1180 registry.register("about", "/about");
1181
1182 let url = registry.url_for("home", &[], &[]).unwrap();
1183 assert_eq!(url, "/");
1184
1185 let url = registry.url_for("about", &[], &[]).unwrap();
1186 assert_eq!(url, "/about");
1187 }
1188
1189 #[test]
1190 fn url_for_with_path_param() {
1191 let mut registry = UrlRegistry::new();
1192 registry.register("get_user", "/users/{id}");
1193
1194 let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1195 assert_eq!(url, "/users/42");
1196 }
1197
1198 #[test]
1199 fn url_for_with_multiple_params() {
1200 let mut registry = UrlRegistry::new();
1201 registry.register("get_post", "/users/{user_id}/posts/{post_id}");
1202
1203 let url = registry
1204 .url_for("get_post", &[("user_id", "42"), ("post_id", "99")], &[])
1205 .unwrap();
1206 assert_eq!(url, "/users/42/posts/99");
1207 }
1208
1209 #[test]
1210 fn url_for_with_typed_param() {
1211 let mut registry = UrlRegistry::new();
1212 registry.register("get_item", "/items/{id:int}");
1213
1214 let url = registry.url_for("get_item", &[("id", "123")], &[]).unwrap();
1216 assert_eq!(url, "/items/123");
1217
1218 let result = registry.url_for("get_item", &[("id", "abc")], &[]);
1220 assert!(matches!(result, Err(UrlError::InvalidParam { .. })));
1221 }
1222
1223 #[test]
1224 fn url_for_with_uuid_param() {
1225 let mut registry = UrlRegistry::new();
1226 registry.register("get_object", "/objects/{id:uuid}");
1227
1228 let url = registry
1229 .url_for(
1230 "get_object",
1231 &[("id", "550e8400-e29b-41d4-a716-446655440000")],
1232 &[],
1233 )
1234 .unwrap();
1235 assert_eq!(url, "/objects/550e8400-e29b-41d4-a716-446655440000");
1236 }
1237
1238 #[test]
1239 fn url_for_with_query_params() {
1240 let mut registry = UrlRegistry::new();
1241 registry.register("search", "/search");
1242
1243 let url = registry
1244 .url_for("search", &[], &[("q", "hello"), ("page", "1")])
1245 .unwrap();
1246 assert_eq!(url, "/search?q=hello&page=1");
1247 }
1248
1249 #[test]
1250 fn url_for_encodes_query_params() {
1251 let mut registry = UrlRegistry::new();
1252 registry.register("search", "/search");
1253
1254 let url = registry
1255 .url_for("search", &[], &[("q", "hello world"), ("filter", "a&b=c")])
1256 .unwrap();
1257 assert_eq!(url, "/search?q=hello%20world&filter=a%26b%3Dc");
1258 }
1259
1260 #[test]
1261 fn url_for_encodes_path_params() {
1262 let mut registry = UrlRegistry::new();
1263 registry.register("get_file", "/files/{name}");
1264
1265 let url = registry
1266 .url_for("get_file", &[("name", "my file.txt")], &[])
1267 .unwrap();
1268 assert_eq!(url, "/files/my%20file.txt");
1269 }
1270
1271 #[test]
1272 fn url_for_with_root_path() {
1273 let mut registry = UrlRegistry::with_root_path("/api/v1");
1274 registry.register("get_user", "/users/{id}");
1275
1276 let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1277 assert_eq!(url, "/api/v1/users/42");
1278 }
1279
1280 #[test]
1281 fn url_for_route_not_found() {
1282 let registry = UrlRegistry::new();
1283 let result = registry.url_for("nonexistent", &[], &[]);
1284 assert!(matches!(result, Err(UrlError::RouteNotFound { name }) if name == "nonexistent"));
1285 }
1286
1287 #[test]
1288 fn url_for_missing_param() {
1289 let mut registry = UrlRegistry::new();
1290 registry.register("get_user", "/users/{id}");
1291
1292 let result = registry.url_for("get_user", &[], &[]);
1293 assert!(matches!(
1294 result,
1295 Err(UrlError::MissingParam { name, param }) if name == "get_user" && param == "id"
1296 ));
1297 }
1298
1299 #[test]
1300 fn url_for_with_path_converter() {
1301 let mut registry = UrlRegistry::new();
1302 registry.register("get_file", "/files/{path:path}");
1303
1304 let url = registry
1305 .url_for("get_file", &[("path", "docs/images/logo.png")], &[])
1306 .unwrap();
1307 assert_eq!(url, "/files/docs/images/logo.png");
1309 }
1310
1311 #[test]
1312 fn url_encode_basic() {
1313 assert_eq!(url_encode("hello"), "hello");
1314 assert_eq!(url_encode("hello world"), "hello%20world");
1315 assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
1316 assert_eq!(url_encode("100%"), "100%25");
1317 }
1318
1319 #[test]
1320 fn url_encode_unicode() {
1321 assert_eq!(url_encode("日本"), "%E6%97%A5%E6%9C%AC");
1322 assert_eq!(url_encode("café"), "caf%C3%A9");
1323 }
1324
1325 #[test]
1326 fn url_decode_basic() {
1327 assert_eq!(url_decode("hello"), Some("hello".to_string()));
1328 assert_eq!(url_decode("hello%20world"), Some("hello world".to_string()));
1329 assert_eq!(url_decode("a%26b%3Dc"), Some("a&b=c".to_string()));
1330 }
1331
1332 #[test]
1333 fn url_decode_plus_as_space() {
1334 assert_eq!(url_decode("hello+world"), Some("hello world".to_string()));
1335 }
1336
1337 #[test]
1338 fn url_decode_invalid() {
1339 assert_eq!(url_decode("hello%2"), None);
1341 assert_eq!(url_decode("hello%"), None);
1342 assert_eq!(url_decode("hello%GG"), None);
1344 }
1345
1346 #[test]
1347 fn url_error_display() {
1348 let err = UrlError::RouteNotFound {
1349 name: "test".to_string(),
1350 };
1351 assert_eq!(format!("{}", err), "route 'test' not found");
1352
1353 let err = UrlError::MissingParam {
1354 name: "get_user".to_string(),
1355 param: "id".to_string(),
1356 };
1357 assert_eq!(
1358 format!("{}", err),
1359 "route 'get_user' requires parameter 'id'"
1360 );
1361
1362 let err = UrlError::InvalidParam {
1363 name: "get_item".to_string(),
1364 param: "id".to_string(),
1365 value: "abc".to_string(),
1366 };
1367 assert_eq!(
1368 format!("{}", err),
1369 "route 'get_item' parameter 'id': invalid value 'abc'"
1370 );
1371 }
1372
1373 #[test]
1374 fn url_registry_route_names_iterator() {
1375 let mut registry = UrlRegistry::new();
1376 registry.register("a", "/a");
1377 registry.register("b", "/b");
1378 registry.register("c", "/c");
1379
1380 let names: Vec<_> = registry.route_names().collect();
1381 assert_eq!(names.len(), 3);
1382 assert!(names.contains(&"a"));
1383 assert!(names.contains(&"b"));
1384 assert!(names.contains(&"c"));
1385 }
1386
1387 #[test]
1388 fn url_registry_set_root_path() {
1389 let mut registry = UrlRegistry::new();
1390 registry.register("home", "/");
1391
1392 let url1 = registry.url_for("home", &[], &[]).unwrap();
1393 assert_eq!(url1, "/");
1394
1395 registry.set_root_path("/api");
1396 let url2 = registry.url_for("home", &[], &[]).unwrap();
1397 assert_eq!(url2, "/api/");
1398 }
1399
1400 #[test]
1405 fn trailing_slash_strict_mode_matches_both_due_to_segment_parsing() {
1406 let mut table = RouteTable::new();
1410 table.add(Method::Get, "/users", "users");
1411
1412 assert!(matches!(
1413 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Strict),
1414 RouteLookup::Match {
1415 route: &"users",
1416 ..
1417 }
1418 ));
1419
1420 assert!(matches!(
1422 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Strict),
1423 RouteLookup::Match {
1424 route: &"users",
1425 ..
1426 }
1427 ));
1428 }
1429
1430 #[test]
1431 fn trailing_slash_redirect_mode_exact_match_no_redirect() {
1432 let mut table = RouteTable::new();
1433 table.add(Method::Get, "/users", "users");
1434
1435 assert!(matches!(
1437 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Redirect),
1438 RouteLookup::Match {
1439 route: &"users",
1440 ..
1441 }
1442 ));
1443
1444 assert!(matches!(
1447 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Redirect),
1448 RouteLookup::Match {
1449 route: &"users",
1450 ..
1451 }
1452 ));
1453 }
1454
1455 #[test]
1456 fn trailing_slash_match_both_mode() {
1457 let mut table = RouteTable::new();
1458 table.add(Method::Get, "/users", "users");
1459
1460 assert!(matches!(
1462 table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::MatchBoth),
1463 RouteLookup::Match {
1464 route: &"users",
1465 ..
1466 }
1467 ));
1468 assert!(matches!(
1469 table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::MatchBoth),
1470 RouteLookup::Match {
1471 route: &"users",
1472 ..
1473 }
1474 ));
1475 }
1476
1477 #[test]
1478 fn trailing_slash_root_path_not_redirected() {
1479 let mut table = RouteTable::new();
1480 table.add(Method::Get, "/", "root");
1481
1482 assert!(matches!(
1483 table.lookup_with_trailing_slash("/", Method::Get, TrailingSlashMode::Redirect),
1484 RouteLookup::Match { route: &"root", .. }
1485 ));
1486 }
1487
1488 #[test]
1489 fn trailing_slash_with_path_params() {
1490 let mut table = RouteTable::new();
1491 table.add(Method::Get, "/users/{id}", "get_user");
1492
1493 match table.lookup_with_trailing_slash(
1495 "/users/42/",
1496 Method::Get,
1497 TrailingSlashMode::MatchBoth,
1498 ) {
1499 RouteLookup::Match { params, .. } => {
1500 assert_eq!(params.len(), 1);
1501 assert_eq!(params[0], ("id".to_string(), "42".to_string()));
1502 }
1503 other => panic!("expected Match, got {:?}", other),
1504 }
1505 }
1506
1507 #[test]
1508 fn trailing_slash_not_found_stays_not_found() {
1509 let mut table = RouteTable::new();
1510 table.add(Method::Get, "/users", "users");
1511
1512 assert!(matches!(
1514 table.lookup_with_trailing_slash(
1515 "/nonexistent",
1516 Method::Get,
1517 TrailingSlashMode::Redirect
1518 ),
1519 RouteLookup::NotFound
1520 ));
1521 assert!(matches!(
1522 table.lookup_with_trailing_slash(
1523 "/nonexistent/",
1524 Method::Get,
1525 TrailingSlashMode::Redirect
1526 ),
1527 RouteLookup::NotFound
1528 ));
1529 }
1530
1531 #[test]
1532 fn trailing_slash_mode_default_is_strict() {
1533 assert_eq!(TrailingSlashMode::default(), TrailingSlashMode::Strict);
1534 }
1535
1536 #[test]
1537 fn app_config_trailing_slash_mode() {
1538 use crate::app::AppConfig;
1539
1540 let config = AppConfig::new();
1541 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Strict);
1542
1543 let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::Redirect);
1544 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Redirect);
1545
1546 let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::MatchBoth);
1547 assert_eq!(config.trailing_slash_mode, TrailingSlashMode::MatchBoth);
1548 }
1549}