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    /// Joins a group prefix and endpoint path using portable syntax.
25    pub fn join(&self, child: &Self) -> SoapResult<Self> {
26        if self.0 == "/" {
27            return Ok(child.clone());
28        }
29        if child.0 == "/" {
30            return Ok(self.clone());
31        }
32        Self::new(format!("{}{}", self.0, child.0))
33    }
34
35    /// Returns a canonical route shape with parameter names removed.
36    ///
37    /// `/users/{id}` and `/users/{user_id}` intentionally share one shape so
38    /// catalogs can reject framework-dependent route conflicts.
39    pub fn shape(&self) -> String {
40        if self.0 == "/" {
41            return "/".to_owned();
42        }
43        self.0
44            .split('/')
45            .map(|segment| {
46                if segment.starts_with('{') && segment.ends_with('}') {
47                    "{}"
48                } else {
49                    segment
50                }
51            })
52            .collect::<Vec<_>>()
53            .join("/")
54    }
55
56    /// Returns declared parameter names in route order.
57    pub fn parameter_names(&self) -> Vec<&str> {
58        self.0
59            .split('/')
60            .filter_map(|segment| segment.strip_prefix('{')?.strip_suffix('}'))
61            .collect()
62    }
63}
64
65impl fmt::Display for RoutePath {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        formatter.write_str(&self.0)
68    }
69}
70
71fn validate_path(path: &str) -> SoapResult<()> {
72    if !path.starts_with('/') {
73        return invalid(path, "must start with `/`");
74    }
75    if path.contains(['?', '#']) || path.chars().any(char::is_whitespace) {
76        return invalid(path, "cannot contain a query, fragment, or whitespace");
77    }
78    if path == "/" {
79        return Ok(());
80    }
81
82    for segment in path.split('/').skip(1) {
83        if segment.is_empty() {
84            return invalid(path, "cannot contain empty segments");
85        }
86        let has_opening_brace = segment.contains('{');
87        let has_closing_brace = segment.contains('}');
88        if has_opening_brace || has_closing_brace {
89            if !(segment.starts_with('{') && segment.ends_with('}')) {
90                return invalid(path, "parameter braces must cover an entire segment");
91            }
92            let parameter = &segment[1..segment.len() - 1];
93            if !valid_parameter(parameter) {
94                return invalid(path, "contains an invalid parameter name");
95            }
96        } else if !segment.chars().all(|character| {
97            character == '-'
98                || character == '_'
99                || character == '.'
100                || character.is_ascii_alphanumeric()
101        }) {
102            return invalid(path, "contains a non-portable static segment");
103        }
104    }
105    Ok(())
106}
107
108fn valid_parameter(parameter: &str) -> bool {
109    let mut characters = parameter.chars();
110    let Some(first) = characters.next() else {
111        return false;
112    };
113    (first == '_' || first.is_ascii_alphabetic())
114        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
115}
116
117fn invalid<T>(path: &str, reason: &str) -> SoapResult<T> {
118    Err(SoapError::validation(format!(
119        "invalid route path `{path}`: {reason}"
120    )))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::RoutePath;
126
127    #[test]
128    fn accepts_root_static_and_parameter_paths() {
129        assert!(RoutePath::new("/").is_ok());
130        assert!(RoutePath::new("/users").is_ok());
131        assert!(RoutePath::new("/users/{user_id}").is_ok());
132    }
133
134    #[test]
135    fn rejects_framework_specific_or_ambiguous_paths() {
136        assert!(RoutePath::new("users").is_err());
137        assert!(RoutePath::new("/users/:id").is_err());
138        assert!(RoutePath::new("/users/{id}/").is_err());
139        assert!(RoutePath::new("/users/{bad-name}").is_err());
140        assert!(RoutePath::new("/users/{id}?active=true").is_err());
141    }
142
143    #[test]
144    fn joins_groups_and_canonicalizes_parameter_shapes() {
145        let prefix = RoutePath::new("/api/v1");
146        let child = RoutePath::new("/users/{user_id}");
147        let same_shape = RoutePath::new("/api/v1/users/{id}");
148        let (Some(prefix), Some(child), Some(same_shape)) =
149            (prefix.ok(), child.ok(), same_shape.ok())
150        else {
151            panic!("valid route fixtures");
152        };
153        let joined = prefix.join(&child);
154        assert_eq!(
155            joined.as_ref().ok().map(RoutePath::as_str),
156            Some("/api/v1/users/{user_id}")
157        );
158        assert_eq!(
159            joined.ok().map(|path| path.shape()),
160            Some(same_shape.shape())
161        );
162        assert_eq!(child.parameter_names(), vec!["user_id"]);
163    }
164}