1use std::collections::BTreeSet;
6use std::fmt;
7
8pub const RESERVED_FIRST_SEGMENTS: [&str; 4] = ["_fn", "_meta", "_jobs", "graphql"];
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Segment {
14 Literal(String),
15 Param(String),
16 CatchAll(String),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RoutePattern {
22 segments: Vec<Segment>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum RoutePathError {
27 NotAbsolute,
28 EmptySegment,
29 InvalidParamName(String),
30 DuplicateParam(String),
31 CatchAllNotLast,
32 ReservedPrefix(String),
33 InvalidLiteral(String),
34}
35
36impl fmt::Display for RoutePathError {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Self::NotAbsolute => write!(f, "a route path must start with '/'"),
40 Self::EmptySegment => write!(f, "a route path may not contain an empty segment"),
41 Self::InvalidParamName(name) => {
42 write!(f, "path parameter '{name}' must match [a-z_][a-z0-9_]*")
43 }
44 Self::DuplicateParam(name) => {
45 write!(f, "path parameter '{name}' appears more than once")
46 }
47 Self::CatchAllNotLast => {
48 write!(f, "a catch-all parameter must be the last segment")
49 }
50 Self::ReservedPrefix(prefix) => write!(
51 f,
52 "the first segment '{prefix}' is reserved by the platform"
53 ),
54 Self::InvalidLiteral(literal) => write!(
55 f,
56 "path segment '{literal}' may contain only letters, digits and . _ ~ -"
57 ),
58 }
59 }
60}
61
62impl std::error::Error for RoutePathError {}
63
64impl RoutePattern {
65 pub fn parse(path: &str) -> Result<Self, RoutePathError> {
68 let rest = path.strip_prefix('/').ok_or(RoutePathError::NotAbsolute)?;
69 let raw: Vec<&str> = if rest.is_empty() {
70 Vec::new()
71 } else {
72 rest.split('/').collect()
73 };
74 let last = raw.len().saturating_sub(1);
75 let segments = raw
76 .iter()
77 .enumerate()
78 .map(|(index, raw)| parse_segment(raw, index == last))
79 .collect::<Result<Vec<_>, _>>()?;
80 let pattern = Self { segments };
81 pattern.check_unique_params()?;
82 match pattern.first_literal() {
83 Some(first) if RESERVED_FIRST_SEGMENTS.contains(&first) => {
84 Err(RoutePathError::ReservedPrefix(first.to_string()))
85 }
86 _ => Ok(pattern),
87 }
88 }
89
90 fn check_unique_params(&self) -> Result<(), RoutePathError> {
91 let mut seen = BTreeSet::new();
92 self.params()
93 .find(|name| !seen.insert(*name))
94 .map_or(Ok(()), |name| {
95 Err(RoutePathError::DuplicateParam(name.to_string()))
96 })
97 }
98
99 pub fn segments(&self) -> &[Segment] {
100 &self.segments
101 }
102
103 pub fn params(&self) -> impl Iterator<Item = &str> {
105 self.segments.iter().filter_map(|segment| match segment {
106 Segment::Param(name) | Segment::CatchAll(name) => Some(name.as_str()),
107 Segment::Literal(_) => None,
108 })
109 }
110
111 pub fn to_matchit(&self) -> String {
113 self.render(|name| format!("{{*{name}}}"))
114 }
115
116 pub fn to_openapi(&self) -> String {
118 self.render(|name| format!("{{{name}}}"))
119 }
120
121 fn render(&self, catch_all: impl Fn(&str) -> String) -> String {
122 if self.segments.is_empty() {
123 return "/".to_string();
124 }
125 self.segments
126 .iter()
127 .map(|segment| match segment {
128 Segment::Literal(literal) => format!("/{literal}"),
129 Segment::Param(name) => format!("/{{{name}}}"),
130 Segment::CatchAll(name) => format!("/{}", catch_all(name)),
131 })
132 .collect()
133 }
134
135 pub fn first_literal(&self) -> Option<&str> {
137 match self.segments.first() {
138 Some(Segment::Literal(literal)) => Some(literal),
139 _ => None,
140 }
141 }
142}
143
144fn parse_segment(raw: &str, last: bool) -> Result<Segment, RoutePathError> {
145 if raw.is_empty() {
146 return Err(RoutePathError::EmptySegment);
147 }
148 let param = raw
149 .strip_prefix('{')
150 .and_then(|inner| inner.strip_suffix('}'))
151 .or_else(|| raw.strip_prefix(':'));
152 match param {
153 Some(inner) => match inner.strip_prefix('*') {
154 Some(_) if !last => Err(RoutePathError::CatchAllNotLast),
155 Some(name) if raw.starts_with('{') => param_name(name).map(Segment::CatchAll),
156 Some(_) => Err(RoutePathError::InvalidParamName(inner.to_string())),
157 None => param_name(inner).map(Segment::Param),
158 },
159 None if raw.bytes().all(is_literal_byte) => Ok(Segment::Literal(raw.to_string())),
160 None => Err(RoutePathError::InvalidLiteral(raw.to_string())),
161 }
162}
163
164fn param_name(name: &str) -> Result<String, RoutePathError> {
165 let mut bytes = name.bytes();
166 let valid = bytes
167 .next()
168 .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
169 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
170 if valid {
171 Ok(name.to_string())
172 } else {
173 Err(RoutePathError::InvalidParamName(name.to_string()))
174 }
175}
176
177fn is_literal_byte(b: u8) -> bool {
178 b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b'-')
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn lit(s: &str) -> Segment {
186 Segment::Literal(s.into())
187 }
188
189 fn param(s: &str) -> Segment {
190 Segment::Param(s.into())
191 }
192
193 #[test]
194 fn parses_literals_params_and_a_trailing_catch_all() {
195 for (path, segments) in [
196 ("/", vec![]),
197 ("/leads", vec![lit("leads")]),
198 (
199 "/leads/{lead_id}/next-call-date",
200 vec![lit("leads"), param("lead_id"), lit("next-call-date")],
201 ),
202 (
203 "/leads/:lead_id/close",
204 vec![lit("leads"), param("lead_id"), lit("close")],
205 ),
206 (
207 "/files/{*rest}",
208 vec![lit("files"), Segment::CatchAll("rest".into())],
209 ),
210 ("/{_id}/v1.2~x", vec![param("_id"), lit("v1.2~x")]),
211 ] {
212 assert_eq!(
213 RoutePattern::parse(path).map(|p| p.segments),
214 Ok(segments),
215 "{path}"
216 );
217 }
218 }
219
220 #[test]
221 fn rejects_malformed_paths_with_the_reason() {
222 for (path, error) in [
223 ("leads", RoutePathError::NotAbsolute),
224 ("", RoutePathError::NotAbsolute),
225 ("/leads/", RoutePathError::EmptySegment),
226 ("//leads", RoutePathError::EmptySegment),
227 (
228 "/leads/{Lead}",
229 RoutePathError::InvalidParamName("Lead".into()),
230 ),
231 ("/leads/{1d}", RoutePathError::InvalidParamName("1d".into())),
232 ("/leads/{}", RoutePathError::InvalidParamName("".into())),
233 (
234 "/leads/:*rest",
235 RoutePathError::InvalidParamName("*rest".into()),
236 ),
237 (
238 "/a/{id}/b/{id}",
239 RoutePathError::DuplicateParam("id".into()),
240 ),
241 ("/a/{id}/{*id}", RoutePathError::DuplicateParam("id".into())),
242 ("/files/{*rest}/x", RoutePathError::CatchAllNotLast),
243 ("/_fn/x", RoutePathError::ReservedPrefix("_fn".into())),
244 ("/_meta", RoutePathError::ReservedPrefix("_meta".into())),
245 ("/_jobs/x", RoutePathError::ReservedPrefix("_jobs".into())),
246 ("/graphql", RoutePathError::ReservedPrefix("graphql".into())),
247 ("/leads/a b", RoutePathError::InvalidLiteral("a b".into())),
248 (
249 "/leads/{id}x",
250 RoutePathError::InvalidLiteral("{id}x".into()),
251 ),
252 ("/leads/%20", RoutePathError::InvalidLiteral("%20".into())),
253 ] {
254 assert_eq!(RoutePattern::parse(path), Err(error), "{path}");
255 }
256 }
257
258 #[test]
259 fn renders_matchit_and_openapi_templates() {
260 let pattern = RoutePattern::parse("/files/:bucket/{*rest}").expect("parses");
261 assert_eq!(pattern.to_matchit(), "/files/{bucket}/{*rest}");
262 assert_eq!(pattern.to_openapi(), "/files/{bucket}/{rest}");
263 assert_eq!(pattern.params().collect::<Vec<_>>(), ["bucket", "rest"]);
264 assert_eq!(pattern.first_literal(), Some("files"));
265 let root = RoutePattern::parse("/").expect("root");
266 assert_eq!(
267 (root.to_matchit(), root.to_openapi()),
268 ("/".into(), "/".into())
269 );
270 assert_eq!(
271 RoutePattern::parse("/{id}").expect("param").first_literal(),
272 None
273 );
274 }
275
276 #[test]
277 fn errors_read_as_sentences() {
278 assert_eq!(
279 RoutePathError::ReservedPrefix("_fn".into()).to_string(),
280 "the first segment '_fn' is reserved by the platform"
281 );
282 }
283}