1#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum PathValidationError {
10 MustStartWithSlash { path: String },
12 EmptySegment { path: String },
14 NestedBraces { path: String, position: usize },
16 UnmatchedClosingBrace { path: String, position: usize },
18 EmptyParameterName { path: String, position: usize },
20 InvalidParameterName { path: String, param_name: String, position: usize },
22 ParameterStartsWithDigit { path: String, param_name: String, position: usize },
24 UnclosedBrace { path: String },
26 InvalidCharacter { path: String, character: char, position: usize },
28}
29
30impl std::fmt::Display for PathValidationError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 PathValidationError::MustStartWithSlash { path } => {
34 write!(f, "route path must start with '/', got: \"{}\"", path)
35 }
36 PathValidationError::EmptySegment { path } => {
37 write!(f, "route path contains empty segment (double slash): \"{}\"", path)
38 }
39 PathValidationError::NestedBraces { path, position } => {
40 write!(f, "nested braces are not allowed in route path at position {}: \"{}\"", position, path)
41 }
42 PathValidationError::UnmatchedClosingBrace { path, position } => {
43 write!(f, "unmatched closing brace '}}' at position {} in route path: \"{}\"", position, path)
44 }
45 PathValidationError::EmptyParameterName { path, position } => {
46 write!(f, "empty parameter name '{{}}' at position {} in route path: \"{}\"", position, path)
47 }
48 PathValidationError::InvalidParameterName { path, param_name, position } => {
49 write!(f, "invalid parameter name '{{{}}}' at position {} - parameter names must contain only alphanumeric characters and underscores: \"{}\"", param_name, position, path)
50 }
51 PathValidationError::ParameterStartsWithDigit { path, param_name, position } => {
52 write!(f, "parameter name '{{{}}}' cannot start with a digit at position {}: \"{}\"", param_name, position, path)
53 }
54 PathValidationError::UnclosedBrace { path } => {
55 write!(f, "unclosed brace '{{' in route path (missing closing '}}'): \"{}\"", path)
56 }
57 PathValidationError::InvalidCharacter { path, character, position } => {
58 write!(f, "invalid character '{}' at position {} in route path: \"{}\"", character, position, path)
59 }
60 }
61 }
62}
63
64impl std::error::Error for PathValidationError {}
65
66pub fn validate_path(path: &str) -> Result<(), PathValidationError> {
103 if !path.starts_with('/') {
105 return Err(PathValidationError::MustStartWithSlash {
106 path: path.to_string(),
107 });
108 }
109
110 if path.contains("//") {
112 return Err(PathValidationError::EmptySegment {
113 path: path.to_string(),
114 });
115 }
116
117 let mut brace_depth = 0;
119 let mut param_start = None;
120
121 for (i, ch) in path.char_indices() {
122 match ch {
123 '{' => {
124 if brace_depth > 0 {
125 return Err(PathValidationError::NestedBraces {
126 path: path.to_string(),
127 position: i,
128 });
129 }
130 brace_depth += 1;
131 param_start = Some(i);
132 }
133 '}' => {
134 if brace_depth == 0 {
135 return Err(PathValidationError::UnmatchedClosingBrace {
136 path: path.to_string(),
137 position: i,
138 });
139 }
140 brace_depth -= 1;
141
142 if let Some(start) = param_start {
144 let param_name = &path[start + 1..i];
145 if param_name.is_empty() {
146 return Err(PathValidationError::EmptyParameterName {
147 path: path.to_string(),
148 position: start,
149 });
150 }
151 if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
153 return Err(PathValidationError::InvalidParameterName {
154 path: path.to_string(),
155 param_name: param_name.to_string(),
156 position: start,
157 });
158 }
159 if param_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
161 return Err(PathValidationError::ParameterStartsWithDigit {
162 path: path.to_string(),
163 param_name: param_name.to_string(),
164 position: start,
165 });
166 }
167 }
168 param_start = None;
169 }
170 _ if brace_depth == 0 => {
172 if !ch.is_alphanumeric() && !"-_./*".contains(ch) {
174 return Err(PathValidationError::InvalidCharacter {
175 path: path.to_string(),
176 character: ch,
177 position: i,
178 });
179 }
180 }
181 _ => {}
182 }
183 }
184
185 if brace_depth > 0 {
187 return Err(PathValidationError::UnclosedBrace {
188 path: path.to_string(),
189 });
190 }
191
192 Ok(())
193}
194
195pub fn is_valid_path(path: &str) -> bool {
197 validate_path(path).is_ok()
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use proptest::prelude::*;
204
205 #[test]
207 fn test_valid_paths() {
208 assert!(validate_path("/").is_ok());
209 assert!(validate_path("/users").is_ok());
210 assert!(validate_path("/users/{id}").is_ok());
211 assert!(validate_path("/users/{user_id}").is_ok());
212 assert!(validate_path("/users/{user_id}/posts").is_ok());
213 assert!(validate_path("/users/{user_id}/posts/{post_id}").is_ok());
214 assert!(validate_path("/api/v1/users").is_ok());
215 assert!(validate_path("/api-v1/users").is_ok());
216 assert!(validate_path("/api_v1/users").is_ok());
217 assert!(validate_path("/api.v1/users").is_ok());
218 assert!(validate_path("/users/*").is_ok()); }
220
221 #[test]
222 fn test_missing_leading_slash() {
223 let result = validate_path("users");
224 assert!(matches!(result, Err(PathValidationError::MustStartWithSlash { .. })));
225
226 let result = validate_path("users/{id}");
227 assert!(matches!(result, Err(PathValidationError::MustStartWithSlash { .. })));
228 }
229
230 #[test]
231 fn test_double_slash() {
232 let result = validate_path("/users//posts");
233 assert!(matches!(result, Err(PathValidationError::EmptySegment { .. })));
234
235 let result = validate_path("//users");
236 assert!(matches!(result, Err(PathValidationError::EmptySegment { .. })));
237 }
238
239 #[test]
240 fn test_unclosed_brace() {
241 let result = validate_path("/users/{id");
242 assert!(matches!(result, Err(PathValidationError::UnclosedBrace { .. })));
243
244 let result = validate_path("/users/{");
245 assert!(matches!(result, Err(PathValidationError::UnclosedBrace { .. })));
246 }
247
248 #[test]
249 fn test_unmatched_closing_brace() {
250 let result = validate_path("/users/id}");
251 assert!(matches!(result, Err(PathValidationError::UnmatchedClosingBrace { .. })));
252
253 let result = validate_path("/users/}");
254 assert!(matches!(result, Err(PathValidationError::UnmatchedClosingBrace { .. })));
255 }
256
257 #[test]
258 fn test_empty_parameter_name() {
259 let result = validate_path("/users/{}");
260 assert!(matches!(result, Err(PathValidationError::EmptyParameterName { .. })));
261
262 let result = validate_path("/users/{}/posts");
263 assert!(matches!(result, Err(PathValidationError::EmptyParameterName { .. })));
264 }
265
266 #[test]
267 fn test_nested_braces() {
268 let result = validate_path("/users/{{id}}");
269 assert!(matches!(result, Err(PathValidationError::NestedBraces { .. })));
270
271 let result = validate_path("/users/{outer{inner}}");
272 assert!(matches!(result, Err(PathValidationError::NestedBraces { .. })));
273 }
274
275 #[test]
276 fn test_parameter_starts_with_digit() {
277 let result = validate_path("/users/{123}");
278 assert!(matches!(result, Err(PathValidationError::ParameterStartsWithDigit { .. })));
279
280 let result = validate_path("/users/{1id}");
281 assert!(matches!(result, Err(PathValidationError::ParameterStartsWithDigit { .. })));
282 }
283
284 #[test]
285 fn test_invalid_parameter_name() {
286 let result = validate_path("/users/{id-name}");
287 assert!(matches!(result, Err(PathValidationError::InvalidParameterName { .. })));
288
289 let result = validate_path("/users/{id.name}");
290 assert!(matches!(result, Err(PathValidationError::InvalidParameterName { .. })));
291
292 let result = validate_path("/users/{id name}");
293 assert!(matches!(result, Err(PathValidationError::InvalidParameterName { .. })));
294 }
295
296 #[test]
297 fn test_invalid_characters() {
298 let result = validate_path("/users?query");
299 assert!(matches!(result, Err(PathValidationError::InvalidCharacter { .. })));
300
301 let result = validate_path("/users#anchor");
302 assert!(matches!(result, Err(PathValidationError::InvalidCharacter { .. })));
303
304 let result = validate_path("/users@domain");
305 assert!(matches!(result, Err(PathValidationError::InvalidCharacter { .. })));
306 }
307
308 proptest! {
315 #![proptest_config(ProptestConfig::with_cases(100))]
316
317 #[test]
326 fn prop_valid_paths_accepted(
327 segments in prop::collection::vec("[a-zA-Z][a-zA-Z0-9_-]{0,10}", 0..5),
329 params in prop::collection::vec("[a-zA-Z_][a-zA-Z0-9_]{0,10}", 0..3),
331 ) {
332 let mut path = String::from("/");
334
335 for (i, segment) in segments.iter().enumerate() {
336 if i > 0 {
337 path.push('/');
338 }
339 path.push_str(segment);
340 }
341
342 for param in params.iter() {
344 if path != "/" {
345 path.push('/');
346 }
347 path.push('{');
348 path.push_str(param);
349 path.push('}');
350 }
351
352 let result = validate_path(&path);
355 prop_assert!(
356 result.is_ok(),
357 "Valid path '{}' should be accepted, but got error: {:?}",
358 path,
359 result.err()
360 );
361 }
362
363 #[test]
368 fn prop_missing_leading_slash_rejected(
369 content in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
371 ) {
372 let path = if content.starts_with('/') {
374 format!("x{}", content)
375 } else {
376 content
377 };
378
379 let result = validate_path(&path);
380 prop_assert!(
381 matches!(result, Err(PathValidationError::MustStartWithSlash { .. })),
382 "Path '{}' without leading slash should be rejected with MustStartWithSlash, got: {:?}",
383 path,
384 result
385 );
386 }
387
388 #[test]
392 fn prop_unclosed_brace_rejected(
393 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
395 param_start in "[a-zA-Z_][a-zA-Z0-9_]{0,5}",
396 ) {
397 let path = format!("{}/{{{}", prefix, param_start);
399
400 let result = validate_path(&path);
401 prop_assert!(
402 matches!(result, Err(PathValidationError::UnclosedBrace { .. })),
403 "Path '{}' with unclosed brace should be rejected with UnclosedBrace, got: {:?}",
404 path,
405 result
406 );
407 }
408
409 #[test]
413 fn prop_unmatched_closing_brace_rejected(
414 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
416 suffix in "[a-zA-Z0-9_]{0,5}",
417 ) {
418 let path = format!("{}/{}}}", prefix, suffix);
420
421 let result = validate_path(&path);
422 prop_assert!(
423 matches!(result, Err(PathValidationError::UnmatchedClosingBrace { .. })),
424 "Path '{}' with unmatched closing brace should be rejected, got: {:?}",
425 path,
426 result
427 );
428 }
429
430 #[test]
434 fn prop_empty_parameter_rejected(
435 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
437 has_suffix in proptest::bool::ANY,
438 suffix_content in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
439 ) {
440 let suffix = if has_suffix {
442 format!("/{}", suffix_content)
443 } else {
444 String::new()
445 };
446 let path = format!("{}/{{}}{}", prefix, suffix);
447
448 let result = validate_path(&path);
449 prop_assert!(
450 matches!(result, Err(PathValidationError::EmptyParameterName { .. })),
451 "Path '{}' with empty parameter should be rejected with EmptyParameterName, got: {:?}",
452 path,
453 result
454 );
455 }
456
457 #[test]
462 fn prop_parameter_starting_with_digit_rejected(
463 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
465 digit in "[0-9]",
466 rest in "[a-zA-Z0-9_]{0,5}",
467 ) {
468 let path = format!("{}/{{{}{}}}", prefix, digit, rest);
470
471 let result = validate_path(&path);
472 prop_assert!(
473 matches!(result, Err(PathValidationError::ParameterStartsWithDigit { .. })),
474 "Path '{}' with parameter starting with digit should be rejected, got: {:?}",
475 path,
476 result
477 );
478 }
479
480 #[test]
484 fn prop_double_slash_rejected(
485 prefix in "/[a-zA-Z0-9_-]{0,10}",
486 suffix in "[a-zA-Z0-9/_-]{0,10}",
487 ) {
488 let path = format!("{}//{}", prefix, suffix);
490
491 let result = validate_path(&path);
492 prop_assert!(
493 matches!(result, Err(PathValidationError::EmptySegment { .. })),
494 "Path '{}' with double slash should be rejected with EmptySegment, got: {:?}",
495 path,
496 result
497 );
498 }
499
500 #[test]
505 fn prop_error_contains_path(
506 invalid_type in 0..5usize,
508 content in "[a-zA-Z][a-zA-Z0-9_]{1,10}",
509 ) {
510 let path = match invalid_type {
511 0 => content.clone(), 1 => format!("/{}//test", content), 2 => format!("/{}/{{", content), 3 => format!("/{}/{{}}", content), 4 => format!("/{}/{{1{content}}}", content = content), _ => content.clone(),
517 };
518
519 let result = validate_path(&path);
520 if let Err(err) = result {
521 let error_message = err.to_string();
522 prop_assert!(
523 error_message.contains(&path) || error_message.contains(&content),
524 "Error message '{}' should contain the path or content for debugging",
525 error_message
526 );
527 }
528 }
529 }
530}