Skip to main content

ploidy_core/parse/
path.rs

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