ploidy_core/parse/
path.rs

1use std::borrow::Cow;
2
3use miette::SourceSpan;
4use winnow::{
5    Parser,
6    combinator::eof,
7    error::{ContextError, ParseError},
8};
9
10/// Parses a path template, like `/v1/pets/{petId}/toy`.
11///
12/// The grammar for path templating is adapted directly from
13/// https://spec.openapis.org/oas/v3.2.0.html#x4-8-2-path-templating.
14pub fn parse<'a>(input: &'a str) -> Result<Vec<PathSegment<'a>>, BadPath> {
15    (self::parser::template, eof)
16        .map(|(segments, _)| segments)
17        .parse(input)
18        .map_err(BadPath::from_parse_error)
19}
20
21/// A slash-delimited path segment that contains zero or more
22/// template fragments.
23#[derive(Clone, Debug, Default, Eq, PartialEq)]
24pub struct PathSegment<'input>(Vec<PathFragment<'input>>);
25
26impl<'input> PathSegment<'input> {
27    pub fn fragments(&self) -> &[PathFragment<'input>] {
28        &self.0
29    }
30}
31
32/// A fragment within a path segment.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum PathFragment<'input> {
35    /// Literal text.
36    Literal(Cow<'input, str>),
37    /// Template parameter name.
38    Param(&'input str),
39}
40
41mod parser {
42    use super::*;
43
44    use winnow::{
45        Parser,
46        combinator::{alt, delimited, repeat},
47        token::take_while,
48    };
49
50    pub fn template<'a>(input: &mut &'a str) -> winnow::Result<Vec<PathSegment<'a>>> {
51        alt((
52            ('/', segment, template)
53                .map(|(_, head, tail)| std::iter::once(head).chain(tail).collect()),
54            ('/', segment).map(|(_, segment)| vec![segment]),
55            '/'.map(|_| vec![PathSegment::default()]),
56        ))
57        .parse_next(input)
58    }
59
60    fn segment<'a>(input: &mut &'a str) -> winnow::Result<PathSegment<'a>> {
61        repeat(1.., fragment).map(PathSegment).parse_next(input)
62    }
63
64    fn fragment<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
65        alt((param, literal)).parse_next(input)
66    }
67
68    pub fn param<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
69        delimited('{', take_while(1.., |c| c != '{' && c != '}'), '}')
70            .map(PathFragment::Param)
71            .parse_next(input)
72    }
73
74    pub fn literal<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
75        take_while(1.., |c| {
76            matches!(c,
77                'A'..='Z' | 'a'..='z' | '0'..='9' |
78                '-' | '.' | '_' | '~' | ':' | '@' |
79                '!' | '$' | '&' | '\'' | '(' | ')' |
80                '*' | '+' | ',' | ';' | '=' | '%'
81            )
82        })
83        .verify_map(|text| {
84            percent_encoding::percent_decode_str(text)
85                .decode_utf8()
86                .ok()
87                .map(PathFragment::Literal)
88        })
89        .parse_next(input)
90    }
91}
92
93#[derive(Debug, miette::Diagnostic, thiserror::Error)]
94#[error("invalid URL path template")]
95pub struct BadPath {
96    #[source_code]
97    code: String,
98    #[label]
99    span: SourceSpan,
100}
101
102impl BadPath {
103    fn from_parse_error(error: ParseError<&str, ContextError>) -> Self {
104        let input = *error.input();
105        Self {
106            code: input.to_owned(),
107            span: error.char_span().into(),
108        }
109    }
110}
111
112#[cfg(test)]
113mod test {
114    use super::*;
115
116    #[test]
117    fn test_root_path() {
118        let result = parse("/").unwrap();
119
120        assert_eq!(result.len(), 1);
121        assert_eq!(result[0].fragments(), &[]);
122    }
123
124    #[test]
125    fn test_simple_literal() {
126        let result = parse("/users").unwrap();
127
128        assert_eq!(result.len(), 1);
129        assert_eq!(
130            result[0].fragments(),
131            &[PathFragment::Literal("users".into())]
132        );
133    }
134
135    #[test]
136    fn test_trailing_slash() {
137        let result = parse("/users/").unwrap();
138
139        assert_eq!(result.len(), 2);
140        assert_eq!(
141            result[0].fragments(),
142            &[PathFragment::Literal("users".into())]
143        );
144        assert_eq!(result[1].fragments(), &[]);
145    }
146
147    #[test]
148    fn test_simple_template() {
149        let result = parse("/users/{userId}").unwrap();
150
151        assert_eq!(result.len(), 2);
152        assert_eq!(
153            result[0].fragments(),
154            &[PathFragment::Literal("users".into())]
155        );
156        assert_eq!(result[1].fragments(), &[PathFragment::Param("userId")]);
157    }
158
159    #[test]
160    fn test_nested_path() {
161        let result = parse("/api/v1/resources/{resourceId}").unwrap();
162
163        assert_eq!(result.len(), 4);
164        assert_eq!(
165            result[0].fragments(),
166            &[PathFragment::Literal("api".into())]
167        );
168        assert_eq!(result[1].fragments(), &[PathFragment::Literal("v1".into())]);
169        assert_eq!(
170            result[2].fragments(),
171            &[PathFragment::Literal("resources".into())]
172        );
173        assert_eq!(result[3].fragments(), &[PathFragment::Param("resourceId")]);
174    }
175
176    #[test]
177    fn test_multiple_templates() {
178        let result = parse("/users/{userId}/posts/{postId}").unwrap();
179
180        assert_eq!(result.len(), 4);
181        assert_eq!(
182            result[0].fragments(),
183            &[PathFragment::Literal("users".into())]
184        );
185        assert_eq!(result[1].fragments(), &[PathFragment::Param("userId")]);
186        assert_eq!(
187            result[2].fragments(),
188            &[PathFragment::Literal("posts".into())]
189        );
190        assert_eq!(result[3].fragments(), &[PathFragment::Param("postId")]);
191    }
192
193    #[test]
194    fn test_literal_with_extension() {
195        let result =
196            parse("/v1/storage/workspace/{workspace}/documents/download/{documentId}.pdf").unwrap();
197
198        assert_eq!(result.len(), 7);
199        assert_eq!(result[0].fragments(), &[PathFragment::Literal("v1".into())]);
200        assert_eq!(
201            result[1].fragments(),
202            &[PathFragment::Literal("storage".into())]
203        );
204        assert_eq!(
205            result[2].fragments(),
206            &[PathFragment::Literal("workspace".into())]
207        );
208        assert_eq!(result[3].fragments(), &[PathFragment::Param("workspace")]);
209        assert_eq!(
210            result[4].fragments(),
211            &[PathFragment::Literal("documents".into())]
212        );
213        assert_eq!(
214            result[5].fragments(),
215            &[PathFragment::Literal("download".into())]
216        );
217        assert_eq!(
218            result[6].fragments(),
219            &[
220                PathFragment::Param("documentId"),
221                PathFragment::Literal(".pdf".into())
222            ]
223        );
224    }
225
226    #[test]
227    fn test_mixed_literal_and_param() {
228        let result =
229            parse("/v1/storage/workspace/{workspace}/documents/download/report-{documentId}.pdf")
230                .unwrap();
231
232        assert_eq!(result.len(), 7);
233        assert_eq!(result[0].fragments(), &[PathFragment::Literal("v1".into())]);
234        assert_eq!(
235            result[1].fragments(),
236            &[PathFragment::Literal("storage".into())]
237        );
238        assert_eq!(
239            result[2].fragments(),
240            &[PathFragment::Literal("workspace".into())]
241        );
242        assert_eq!(result[3].fragments(), &[PathFragment::Param("workspace")]);
243        assert_eq!(
244            result[4].fragments(),
245            &[PathFragment::Literal("documents".into())]
246        );
247        assert_eq!(
248            result[5].fragments(),
249            &[PathFragment::Literal("download".into())]
250        );
251        assert_eq!(
252            result[6].fragments(),
253            &[
254                PathFragment::Literal("report-".into()),
255                PathFragment::Param("documentId"),
256                PathFragment::Literal(".pdf".into())
257            ]
258        );
259    }
260
261    #[test]
262    fn test_double_slash() {
263        // Empty path segments aren't allowed.
264        assert!(parse("/users//a").is_err());
265    }
266
267    #[test]
268    fn test_invalid_chars_in_template() {
269        // Parameter names can contain any character except for
270        // `{` and `}`, per the `template-expression-param-name` terminal.
271        assert!(parse("/users/{user/{id}}").is_err());
272    }
273}