Skip to main content

linkrs_server/http/
typed_path.rs

1//! Type-safe path definitions for HTTP routes
2//!
3//! This module provides compile-time and runtime validation for route paths,
4//! preventing issues like using `:param` syntax instead of `{param}` in axum 0.7+.
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9/// Validates that a path uses the correct axum 0.7+ syntax `{param}` instead of `:param`
10pub fn validate_path(path: &str) -> Result<(), PathValidationError> {
11    static OLD_SYNTAX_REGEX: OnceLock<Regex> = OnceLock::new();
12    let regex = OLD_SYNTAX_REGEX
13        .get_or_init(|| Regex::new(r":([a-zA-Z_][a-zA-Z0-9_]*)").expect("Invalid regex pattern"));
14
15    if let Some(captures) = regex.captures(path) {
16        let param_name = captures.get(1).map(|m| m.as_str()).unwrap_or("");
17        return Err(PathValidationError::OldSyntax {
18            path: path.to_string(),
19            param: param_name.to_string(),
20        });
21    }
22
23    Ok(())
24}
25
26/// Error type for path validation failures
27#[derive(Debug, Clone, PartialEq)]
28pub enum PathValidationError {
29    OldSyntax { path: String, param: String },
30}
31
32impl std::fmt::Display for PathValidationError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            PathValidationError::OldSyntax { path, param } => {
36                write!(
37                    f,
38                    "Path '{}' uses old syntax ':{}'. Use '{{{}}}' instead (axum 0.7+ syntax)",
39                    path, param, param
40                )
41            }
42        }
43    }
44}
45
46impl std::error::Error for PathValidationError {}
47
48/// A type-safe path that validates the format at construction time
49#[derive(Debug, Clone, PartialEq)]
50pub struct TypedPath(String);
51
52impl TypedPath {
53    /// Creates a new TypedPath, validating the format
54    pub fn new(path: &str) -> Result<Self, PathValidationError> {
55        validate_path(path)?;
56        Ok(Self(path.to_string()))
57    }
58
59    /// Creates a new TypedPath without validation (use with caution)
60    pub fn new_unchecked(path: &str) -> Self {
61        Self(path.to_string())
62    }
63
64    /// Returns the path string
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68
69    /// Creates a path with a single parameter
70    pub fn with_param(base: &str, param_name: &str) -> Self {
71        Self(format!("{}/{{{}}}", base.trim_end_matches('/'), param_name))
72    }
73
74    /// Creates a path with multiple parameters
75    pub fn with_params(base: &str, params: &[&str]) -> Self {
76        let mut path = base.trim_end_matches('/').to_string();
77        for param in params {
78            path.push_str(&format!("/{{{}}}", param));
79        }
80        Self(path)
81    }
82
83    /// Appends a static segment to the path
84    pub fn append(mut self, segment: &str) -> Self {
85        self.0.push_str(segment);
86        Self::new(&self.0).unwrap_or(self)
87    }
88}
89
90impl AsRef<str> for TypedPath {
91    fn as_ref(&self) -> &str {
92        &self.0
93    }
94}
95
96impl From<TypedPath> for String {
97    fn from(path: TypedPath) -> Self {
98        path.0
99    }
100}
101
102/// Predefined path segments for common patterns
103pub mod segments {
104    use super::TypedPath;
105
106    /// Creates a path with an `id` parameter: `/resource/{id}`
107    pub fn with_id(resource: &str) -> TypedPath {
108        TypedPath::with_param(resource, "id")
109    }
110
111    /// Creates a nested resource path: `/parent/{parent_id}/children`
112    pub fn nested(parent: &str, parent_id: &str, child: &str) -> TypedPath {
113        TypedPath(format!("/{}/{{{}}}/{}", parent, parent_id, child))
114    }
115
116    /// Creates a nested resource with its own id: `/parent/{id}/child/{child_id}`
117    pub fn nested_with_id(parent: &str, child: &str) -> TypedPath {
118        TypedPath(format!("/{}/{{id}}/{}/{{{}_id}}", parent, child, child))
119    }
120}
121
122/// Macro for compile-time path validation
123#[macro_export]
124macro_rules! path {
125    ($path:literal) => {{
126        // Runtime validation in debug builds
127        #[cfg(debug_assertions)]
128        {
129            use $crate::http::typed_path::validate_path;
130            validate_path($path).expect("Invalid path format")
131        }
132        $path
133    }};
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_validate_path_valid() {
142        assert!(validate_path("/sessions/{id}").is_ok());
143        assert!(validate_path("/config/{section}/{key}").is_ok());
144        assert!(validate_path("/static/path").is_ok());
145    }
146
147    #[test]
148    fn test_validate_path_invalid() {
149        let result = validate_path("/sessions/:id");
150        assert!(matches!(
151            result,
152            Err(PathValidationError::OldSyntax { param, .. }) if param == "id"
153        ));
154
155        let result = validate_path("/config/:section/:key");
156        assert!(matches!(
157            result,
158            Err(PathValidationError::OldSyntax { param, .. }) if param == "section"
159        ));
160    }
161
162    #[test]
163    fn test_typed_path_new() {
164        let path = TypedPath::new("/sessions/{id}").unwrap();
165        assert_eq!(path.as_str(), "/sessions/{id}");
166
167        let result = TypedPath::new("/sessions/:id");
168        assert!(result.is_err());
169    }
170
171    #[test]
172    fn test_typed_path_with_param() {
173        let path = TypedPath::with_param("/sessions", "id");
174        assert_eq!(path.as_str(), "/sessions/{id}");
175    }
176
177    #[test]
178    fn test_segments_with_id() {
179        let path = segments::with_id("sessions");
180        assert_eq!(path.as_str(), "sessions/{id}");
181    }
182}