Skip to main content

soaprs_http/
path.rs

1//! Portable route path syntax.
2
3use std::fmt;
4
5use soaprs_core::{SoapError, SoapResult};
6
7/// A validated route path using `{parameter}` placeholders.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct RoutePath(String);
10
11impl RoutePath {
12    /// Validates and creates a route path.
13    pub fn new(path: impl Into<String>) -> SoapResult<Self> {
14        let path = path.into();
15        validate_path(&path)?;
16        Ok(Self(path))
17    }
18
19    /// Returns the portable route path.
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl fmt::Display for RoutePath {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        formatter.write_str(&self.0)
28    }
29}
30
31fn validate_path(path: &str) -> SoapResult<()> {
32    if !path.starts_with('/') {
33        return invalid(path, "must start with `/`");
34    }
35    if path.contains(['?', '#']) || path.chars().any(char::is_whitespace) {
36        return invalid(path, "cannot contain a query, fragment, or whitespace");
37    }
38    if path == "/" {
39        return Ok(());
40    }
41
42    for segment in path.split('/').skip(1) {
43        if segment.is_empty() {
44            return invalid(path, "cannot contain empty segments");
45        }
46        let has_opening_brace = segment.contains('{');
47        let has_closing_brace = segment.contains('}');
48        if has_opening_brace || has_closing_brace {
49            if !(segment.starts_with('{') && segment.ends_with('}')) {
50                return invalid(path, "parameter braces must cover an entire segment");
51            }
52            let parameter = &segment[1..segment.len() - 1];
53            if !valid_parameter(parameter) {
54                return invalid(path, "contains an invalid parameter name");
55            }
56        } else if !segment.chars().all(|character| {
57            character == '-'
58                || character == '_'
59                || character == '.'
60                || character.is_ascii_alphanumeric()
61        }) {
62            return invalid(path, "contains a non-portable static segment");
63        }
64    }
65    Ok(())
66}
67
68fn valid_parameter(parameter: &str) -> bool {
69    let mut characters = parameter.chars();
70    let Some(first) = characters.next() else {
71        return false;
72    };
73    (first == '_' || first.is_ascii_alphabetic())
74        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
75}
76
77fn invalid<T>(path: &str, reason: &str) -> SoapResult<T> {
78    Err(SoapError::validation(format!(
79        "invalid route path `{path}`: {reason}"
80    )))
81}
82
83#[cfg(test)]
84mod tests {
85    use super::RoutePath;
86
87    #[test]
88    fn accepts_root_static_and_parameter_paths() {
89        assert!(RoutePath::new("/").is_ok());
90        assert!(RoutePath::new("/users").is_ok());
91        assert!(RoutePath::new("/users/{user_id}").is_ok());
92    }
93
94    #[test]
95    fn rejects_framework_specific_or_ambiguous_paths() {
96        assert!(RoutePath::new("users").is_err());
97        assert!(RoutePath::new("/users/:id").is_err());
98        assert!(RoutePath::new("/users/{id}/").is_err());
99        assert!(RoutePath::new("/users/{bad-name}").is_err());
100        assert!(RoutePath::new("/users/{id}?active=true").is_err());
101    }
102}