linkrs_server/http/
typed_path.rs1use regex::Regex;
7use std::sync::OnceLock;
8
9pub 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#[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#[derive(Debug, Clone, PartialEq)]
50pub struct TypedPath(String);
51
52impl TypedPath {
53 pub fn new(path: &str) -> Result<Self, PathValidationError> {
55 validate_path(path)?;
56 Ok(Self(path.to_string()))
57 }
58
59 pub fn new_unchecked(path: &str) -> Self {
61 Self(path.to_string())
62 }
63
64 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68
69 pub fn with_param(base: &str, param_name: &str) -> Self {
71 Self(format!("{}/{{{}}}", base.trim_end_matches('/'), param_name))
72 }
73
74 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 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
102pub mod segments {
104 use super::TypedPath;
105
106 pub fn with_id(resource: &str) -> TypedPath {
108 TypedPath::with_param(resource, "id")
109 }
110
111 pub fn nested(parent: &str, parent_id: &str, child: &str) -> TypedPath {
113 TypedPath(format!("/{}/{{{}}}/{}", parent, parent_id, child))
114 }
115
116 pub fn nested_with_id(parent: &str, child: &str) -> TypedPath {
118 TypedPath(format!("/{}/{{id}}/{}/{{{}_id}}", parent, child, child))
119 }
120}
121
122#[macro_export]
124macro_rules! path {
125 ($path:literal) => {{
126 #[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}