Skip to main content

kynos_openapi/model/paths/
template.rs

1//! Path templating: parsing, normalization and prefixing.
2
3use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7/// The error returned when a path template is malformed.
8#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
9#[non_exhaustive]
10pub enum InvalidPathTemplate {
11    /// The template did not begin with `/`.
12    #[error("path template `{0}` must begin with `/`")]
13    MissingLeadingSlash(String),
14
15    /// A `{` was opened but never closed, or a `}` appeared unopened.
16    #[error("path template `{0}` has unbalanced braces")]
17    UnbalancedBraces(String),
18
19    /// A `{}` expression contained no name.
20    #[error("path template `{0}` contains an empty `{{}}` expression")]
21    EmptyExpression(String),
22
23    /// The same variable name appeared more than once.
24    ///
25    /// A template expression must not be repeated within one path.
26    #[error("path template `{template}` repeats the variable `{name}`")]
27    DuplicateVariable {
28        /// The offending template.
29        template: String,
30        /// The variable that appeared more than once.
31        name: String,
32    },
33
34    /// The template contained a query string or fragment.
35    #[error("path template `{0}` must not contain a query string or fragment")]
36    NotAPath(String),
37
38    /// A literal segment contained a character the path grammar forbids.
39    ///
40    /// Outside a `{}` expression a template may only carry `pchar`: letters,
41    /// digits, `-._~`, the sub-delimiters `!$&'()*+,;=`, `:`, `@`, and
42    /// percent-encoded triples. Anything else — including any non-ASCII
43    /// character — has to arrive percent-encoded.
44    #[error(
45        "path template `{template}` contains `{character}` outside a `{{}}` expression, which the \
46         path grammar does not allow"
47    )]
48    IllegalLiteralCharacter {
49        /// The offending template.
50        template: String,
51        /// The character that is not allowed there.
52        character: char,
53    },
54
55    /// A `%` was not followed by two hexadecimal digits.
56    #[error("path template `{0}` contains a `%` that does not introduce a percent-encoded triple")]
57    MalformedPercentEncoding(String),
58
59    /// Two `/` met with nothing between them.
60    ///
61    /// A path segment always holds at least one character. A *trailing* `/` is
62    /// legal, because the grammar makes the final segment optional.
63    #[error("path template `{0}` has an empty segment")]
64    EmptySegment(String),
65}
66
67/// A parsed path template such as `/users/{id}/posts/{postId}`.
68///
69/// Two templates that differ only in variable name are *the same path* as far
70/// as OpenAPI is concerned, so declaring both is invalid.
71/// [`normalized`](PathTemplate::normalized) exists to make that comparison
72/// cheap.
73#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(try_from = "String", into = "String")]
75pub struct PathTemplate {
76    raw: String,
77    variables: Vec<String>,
78}
79
80/// Whether `character` is `pchar`, the only thing a path literal may hold.
81///
82/// `pchar = unreserved / pct-encoded / sub-delims / ":" / "@"`, per RFC 3986
83/// section 3.3. `%` is handled by the caller, which has the following two
84/// characters in hand.
85const fn is_path_character(character: char) -> bool {
86    character.is_ascii_alphanumeric()
87        || matches!(
88            character,
89            '-' | '.'
90                | '_'
91                | '~'
92                | '!'
93                | '$'
94                | '&'
95                | '\''
96                | '('
97                | ')'
98                | '*'
99                | '+'
100                | ','
101                | ';'
102                | '='
103                | ':'
104                | '@'
105        )
106}
107
108/// Checks one literal run of a template, between `{}` expressions.
109///
110/// `/` is the segment separator rather than `pchar`, so it is allowed here and
111/// segmentation is left to callers that care about it.
112fn check_literal(literal: &str, raw: &str) -> Result<(), InvalidPathTemplate> {
113    let mut characters = literal.chars();
114    while let Some(character) = characters.next() {
115        match character {
116            '/' => {}
117            // Not `pchar` either, but a closing brace outside an expression is
118            // a brace mistake wherever it appears, and reporting it as a stray
119            // character would name the wrong problem.
120            '}' => return Err(InvalidPathTemplate::UnbalancedBraces(raw.to_owned())),
121            '%' => {
122                let high = characters.next();
123                let low = characters.next();
124                if !matches!((high, low), (Some(high), Some(low))
125                    if high.is_ascii_hexdigit() && low.is_ascii_hexdigit())
126                {
127                    return Err(InvalidPathTemplate::MalformedPercentEncoding(
128                        raw.to_owned(),
129                    ));
130                }
131            }
132            _ if is_path_character(character) => {}
133            _ => {
134                return Err(InvalidPathTemplate::IllegalLiteralCharacter {
135                    template: raw.to_owned(),
136                    character,
137                });
138            }
139        }
140    }
141    Ok(())
142}
143
144/// Rejects a segment with nothing in it.
145///
146/// `path-template = "/" *( path-segment "/" ) [ path-segment ]` and
147/// `path-segment = 1*( path-literal / template-expression )`, so two `/` never
148/// meet. A *trailing* `/` is legal, because the final segment is optional --
149/// and `/users` and `/users/` are different paths, which is what makes the
150/// trailing-slash policy an application-level decision rather than a parse
151/// question.
152///
153/// A variable name may itself contain a `/`, so this cannot be a split.
154fn check_segments(raw: &str) -> Result<(), InvalidPathTemplate> {
155    let mut in_expression = false;
156    let mut segment_is_empty = true;
157
158    // The leading `/` opens the first segment rather than closing one.
159    for character in raw.chars().skip(1) {
160        match character {
161            '{' => {
162                in_expression = true;
163                segment_is_empty = false;
164            }
165            '}' => in_expression = false,
166            '/' if !in_expression => {
167                if segment_is_empty {
168                    return Err(InvalidPathTemplate::EmptySegment(raw.to_owned()));
169                }
170                segment_is_empty = true;
171            }
172            _ => segment_is_empty = false,
173        }
174    }
175
176    Ok(())
177}
178
179impl PathTemplate {
180    /// Parses a path template.
181    ///
182    /// Literal segments are checked against the path grammar; variable names
183    /// are not, because the grammar admits every character except a brace
184    /// there. A name that Kynos's router cannot match — a catch-all, say — is
185    /// still a legal OpenAPI template, and this type has to be able to hold one
186    /// so that an externally authored description round-trips. That narrower
187    /// contract is enforced where routes are registered.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`InvalidPathTemplate`] when the template does not start with
192    /// `/`, has unbalanced or empty braces, repeats a variable, carries a query
193    /// string or fragment, or holds a character the path grammar does not allow
194    /// outside a `{}` expression.
195    pub fn parse(raw: impl Into<String>) -> Result<Self, InvalidPathTemplate> {
196        let raw = raw.into();
197
198        if !raw.starts_with('/') {
199            return Err(InvalidPathTemplate::MissingLeadingSlash(raw));
200        }
201        // `?` and `#` are not `pchar` either, but a template carrying one is
202        // more likely a URL pasted whole than a stray character, so it keeps
203        // the error that says so.
204        if raw.contains('?') || raw.contains('#') {
205            return Err(InvalidPathTemplate::NotAPath(raw));
206        }
207
208        let mut variables = Vec::new();
209        let mut rest = raw.as_str();
210        while let Some(open) = rest.find('{') {
211            check_literal(&rest[..open], &raw)?;
212            let after_open = &rest[open + 1..];
213            let Some(close) = after_open.find('}') else {
214                return Err(InvalidPathTemplate::UnbalancedBraces(raw));
215            };
216            let name = &after_open[..close];
217            if name.is_empty() {
218                return Err(InvalidPathTemplate::EmptyExpression(raw));
219            }
220            if name.contains('{') {
221                return Err(InvalidPathTemplate::UnbalancedBraces(raw));
222            }
223            if variables.iter().any(|existing| existing == name) {
224                return Err(InvalidPathTemplate::DuplicateVariable {
225                    name: name.to_owned(),
226                    template: raw,
227                });
228            }
229            variables.push(name.to_owned());
230            rest = &after_open[close + 1..];
231        }
232        if rest.contains('}') {
233            return Err(InvalidPathTemplate::UnbalancedBraces(raw));
234        }
235        check_literal(rest, &raw)?;
236        check_segments(&raw)?;
237
238        Ok(Self { raw, variables })
239    }
240
241    /// The template exactly as written.
242    #[must_use]
243    pub fn as_str(&self) -> &str {
244        &self.raw
245    }
246
247    /// The variable names, in the order they appear.
248    #[must_use]
249    pub fn variables(&self) -> &[String] {
250        &self.variables
251    }
252
253    /// The template with every variable name replaced by `{}`.
254    ///
255    /// Two templates are the same path if and only if their normalized forms
256    /// are equal.
257    #[must_use]
258    pub fn normalized(&self) -> String {
259        let mut out = String::with_capacity(self.raw.len());
260        let mut rest = self.raw.as_str();
261        while let Some(open) = rest.find('{') {
262            out.push_str(&rest[..open]);
263            out.push_str("{}");
264            let after_open = &rest[open + 1..];
265            let close = after_open.find('}').expect("parse validated the braces");
266            rest = &after_open[close + 1..];
267        }
268        out.push_str(rest);
269        out
270    }
271
272    /// Concatenates a prefix onto this template, as nesting does.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`InvalidPathTemplate`] when the result is not a valid template,
277    /// which is how a prefix that repeats one of this template's variables is
278    /// caught.
279    pub fn with_prefix(&self, prefix: &str) -> Result<Self, InvalidPathTemplate> {
280        let prefix = prefix.trim_end_matches('/');
281        if prefix.is_empty() {
282            return Ok(self.clone());
283        }
284        Self::parse(format!("{prefix}{}", self.raw))
285    }
286}
287
288impl fmt::Display for PathTemplate {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        f.write_str(&self.raw)
291    }
292}
293
294impl FromStr for PathTemplate {
295    type Err = InvalidPathTemplate;
296
297    fn from_str(value: &str) -> Result<Self, Self::Err> {
298        Self::parse(value)
299    }
300}
301
302impl TryFrom<String> for PathTemplate {
303    type Error = InvalidPathTemplate;
304
305    fn try_from(value: String) -> Result<Self, Self::Error> {
306        Self::parse(value)
307    }
308}
309
310impl From<PathTemplate> for String {
311    fn from(template: PathTemplate) -> Self {
312        template.raw
313    }
314}