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}