Skip to main content

cufflink_types/
route_path.rs

1//! Route path patterns of `routing: strict` services: parsed once here and
2//! shared by the `service!` macro, the deploy validator, the platform's route
3//! table and the OpenAPI generator.
4
5use std::collections::BTreeSet;
6use std::fmt;
7
8/// First path segments the platform serves itself under every service, which a
9/// strict route may not claim.
10pub 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/// A parsed route path such as `/leads/{lead_id}/notes`.
20#[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    /// Accepts `{name}` and the legacy `:name`; `{*rest}` only as the last
66    /// segment. `/` alone is the service root.
67    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    /// Parameter names in path order, the catch-all included.
104    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    /// The pattern in `matchit` syntax: `/leads/{lead_id}/close`.
112    pub fn to_matchit(&self) -> String {
113        self.render(|name| format!("{{*{name}}}"))
114    }
115
116    /// The pattern as an OpenAPI path template, the catch-all as `{rest}`.
117    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    /// The first segment when it is a literal.
136    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}