1use std::fmt;
4
5use soaprs_core::{SoapError, SoapResult};
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct RoutePath(String);
10
11impl RoutePath {
12 pub fn new(path: impl Into<String>) -> SoapResult<Self> {
14 let path = path.into();
15 validate_path(&path)?;
16 Ok(Self(path))
17 }
18
19 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23
24 pub fn join(&self, child: &Self) -> SoapResult<Self> {
26 if self.0 == "/" {
27 return Ok(child.clone());
28 }
29 if child.0 == "/" {
30 return Ok(self.clone());
31 }
32 Self::new(format!("{}{}", self.0, child.0))
33 }
34
35 pub fn shape(&self) -> String {
40 if self.0 == "/" {
41 return "/".to_owned();
42 }
43 self.0
44 .split('/')
45 .map(|segment| {
46 if segment.starts_with('{') && segment.ends_with('}') {
47 "{}"
48 } else {
49 segment
50 }
51 })
52 .collect::<Vec<_>>()
53 .join("/")
54 }
55
56 pub fn parameter_names(&self) -> Vec<&str> {
58 self.0
59 .split('/')
60 .filter_map(|segment| segment.strip_prefix('{')?.strip_suffix('}'))
61 .collect()
62 }
63}
64
65impl fmt::Display for RoutePath {
66 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67 formatter.write_str(&self.0)
68 }
69}
70
71fn validate_path(path: &str) -> SoapResult<()> {
72 if !path.starts_with('/') {
73 return invalid(path, "must start with `/`");
74 }
75 if path.contains(['?', '#']) || path.chars().any(char::is_whitespace) {
76 return invalid(path, "cannot contain a query, fragment, or whitespace");
77 }
78 if path == "/" {
79 return Ok(());
80 }
81
82 for segment in path.split('/').skip(1) {
83 if segment.is_empty() {
84 return invalid(path, "cannot contain empty segments");
85 }
86 let has_opening_brace = segment.contains('{');
87 let has_closing_brace = segment.contains('}');
88 if has_opening_brace || has_closing_brace {
89 if !(segment.starts_with('{') && segment.ends_with('}')) {
90 return invalid(path, "parameter braces must cover an entire segment");
91 }
92 let parameter = &segment[1..segment.len() - 1];
93 if !valid_parameter(parameter) {
94 return invalid(path, "contains an invalid parameter name");
95 }
96 } else if !segment.chars().all(|character| {
97 character == '-'
98 || character == '_'
99 || character == '.'
100 || character.is_ascii_alphanumeric()
101 }) {
102 return invalid(path, "contains a non-portable static segment");
103 }
104 }
105 Ok(())
106}
107
108fn valid_parameter(parameter: &str) -> bool {
109 let mut characters = parameter.chars();
110 let Some(first) = characters.next() else {
111 return false;
112 };
113 (first == '_' || first.is_ascii_alphabetic())
114 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
115}
116
117fn invalid<T>(path: &str, reason: &str) -> SoapResult<T> {
118 Err(SoapError::validation(format!(
119 "invalid route path `{path}`: {reason}"
120 )))
121}
122
123#[cfg(test)]
124mod tests {
125 use super::RoutePath;
126
127 #[test]
128 fn accepts_root_static_and_parameter_paths() {
129 assert!(RoutePath::new("/").is_ok());
130 assert!(RoutePath::new("/users").is_ok());
131 assert!(RoutePath::new("/users/{user_id}").is_ok());
132 }
133
134 #[test]
135 fn rejects_framework_specific_or_ambiguous_paths() {
136 assert!(RoutePath::new("users").is_err());
137 assert!(RoutePath::new("/users/:id").is_err());
138 assert!(RoutePath::new("/users/{id}/").is_err());
139 assert!(RoutePath::new("/users/{bad-name}").is_err());
140 assert!(RoutePath::new("/users/{id}?active=true").is_err());
141 }
142
143 #[test]
144 fn joins_groups_and_canonicalizes_parameter_shapes() {
145 let prefix = RoutePath::new("/api/v1");
146 let child = RoutePath::new("/users/{user_id}");
147 let same_shape = RoutePath::new("/api/v1/users/{id}");
148 let (Some(prefix), Some(child), Some(same_shape)) =
149 (prefix.ok(), child.ok(), same_shape.ok())
150 else {
151 panic!("valid route fixtures");
152 };
153 let joined = prefix.join(&child);
154 assert_eq!(
155 joined.as_ref().ok().map(RoutePath::as_str),
156 Some("/api/v1/users/{user_id}")
157 );
158 assert_eq!(
159 joined.ok().map(|path| path.shape()),
160 Some(same_shape.shape())
161 );
162 assert_eq!(child.parameter_names(), vec!["user_id"]);
163 }
164}