rustapi_core/
path_validation.rs

1//! Route path validation utilities
2//!
3//! This module provides compile-time and runtime validation for route paths.
4//! The validation logic is shared between the proc-macro crate (for compile-time
5//! validation) and the core crate (for runtime validation and testing).
6
7/// Result of path validation
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum PathValidationError {
10    /// Path must start with '/'
11    MustStartWithSlash { path: String },
12    /// Path contains empty segment (double slash)
13    EmptySegment { path: String },
14    /// Nested braces are not allowed
15    NestedBraces { path: String, position: usize },
16    /// Unmatched closing brace
17    UnmatchedClosingBrace { path: String, position: usize },
18    /// Empty parameter name
19    EmptyParameterName { path: String, position: usize },
20    /// Invalid parameter name (contains invalid characters)
21    InvalidParameterName { path: String, param_name: String, position: usize },
22    /// Parameter name starts with digit
23    ParameterStartsWithDigit { path: String, param_name: String, position: usize },
24    /// Unclosed brace
25    UnclosedBrace { path: String },
26    /// Invalid character in path
27    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
66/// Validate route path syntax
67/// 
68/// Returns Ok(()) if the path is valid, or Err with a descriptive error.
69/// 
70/// # Valid paths
71/// - Must start with '/'
72/// - Can contain alphanumeric characters, '-', '_', '.', '/'
73/// - Can contain path parameters in the form `{param_name}`
74/// - Parameter names must be valid identifiers (alphanumeric + underscore, not starting with digit)
75/// 
76/// # Invalid paths
77/// - Paths not starting with '/'
78/// - Paths with empty segments (double slashes like '//')
79/// - Paths with unclosed or nested braces
80/// - Paths with empty parameter names like '{}'
81/// - Paths with invalid parameter names
82/// - Paths with invalid characters
83/// 
84/// # Examples
85/// 
86/// ```
87/// use rustapi_core::path_validation::validate_path;
88/// 
89/// // Valid paths
90/// assert!(validate_path("/").is_ok());
91/// assert!(validate_path("/users").is_ok());
92/// assert!(validate_path("/users/{id}").is_ok());
93/// assert!(validate_path("/users/{user_id}/posts/{post_id}").is_ok());
94/// 
95/// // Invalid paths
96/// assert!(validate_path("users").is_err()); // Missing leading /
97/// assert!(validate_path("/users//posts").is_err()); // Double slash
98/// assert!(validate_path("/users/{").is_err()); // Unclosed brace
99/// assert!(validate_path("/users/{}").is_err()); // Empty parameter
100/// assert!(validate_path("/users/{123}").is_err()); // Parameter starts with digit
101/// ```
102pub fn validate_path(path: &str) -> Result<(), PathValidationError> {
103    // Path must start with /
104    if !path.starts_with('/') {
105        return Err(PathValidationError::MustStartWithSlash {
106            path: path.to_string(),
107        });
108    }
109
110    // Check for empty path segments (double slashes)
111    if path.contains("//") {
112        return Err(PathValidationError::EmptySegment {
113            path: path.to_string(),
114        });
115    }
116
117    // Validate path parameter syntax
118    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                // Check that parameter name is not empty
143                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                    // Validate parameter name contains only valid identifier characters
152                    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                    // Parameter name must not start with a digit
160                    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            // Check for invalid characters in path (outside of parameters)
171            _ if brace_depth == 0 => {
172                // Allow alphanumeric, -, _, ., /, and common URL characters
173                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    // Check for unclosed braces
186    if brace_depth > 0 {
187        return Err(PathValidationError::UnclosedBrace {
188            path: path.to_string(),
189        });
190    }
191
192    Ok(())
193}
194
195/// Check if a path is valid (convenience function)
196pub 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    // Unit tests for specific cases
206    #[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()); // Wildcard
219    }
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    // **Feature: phase4-ergonomics-v1, Property 2: Invalid Path Syntax Rejection**
309    //
310    // For any route path string that contains invalid syntax (e.g., unclosed braces,
311    // invalid characters), the system should reject it with a clear error message.
312    //
313    // **Validates: Requirements 1.5**
314    proptest! {
315        #![proptest_config(ProptestConfig::with_cases(100))]
316
317        /// Property: Valid paths are accepted
318        /// 
319        /// For any path that follows the valid path structure:
320        /// - Starts with /
321        /// - Contains only valid segments (alphanumeric, -, _, .)
322        /// - Has properly formed parameters {name} where name is a valid identifier
323        /// 
324        /// The validation should succeed.
325        #[test]
326        fn prop_valid_paths_accepted(
327            // Generate valid path segments (non-empty to avoid double slashes)
328            segments in prop::collection::vec("[a-zA-Z][a-zA-Z0-9_-]{0,10}", 0..5),
329            // Generate valid parameter names (must start with letter or underscore)
330            params in prop::collection::vec("[a-zA-Z_][a-zA-Z0-9_]{0,10}", 0..3),
331        ) {
332            // Build a valid path from segments and parameters
333            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            // Add parameters at the end (only if we have segments or it's the root)
343            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            // If path is just "/", that's valid
353            // Otherwise ensure we have a valid structure
354            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        /// Property: Paths without leading slash are rejected
364        /// 
365        /// For any path that doesn't start with '/', validation should fail
366        /// with MustStartWithSlash error.
367        #[test]
368        fn prop_missing_leading_slash_rejected(
369            // Generate path content that doesn't start with /
370            content in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
371        ) {
372            // Ensure the path doesn't start with /
373            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        /// Property: Paths with unclosed braces are rejected
389        /// 
390        /// For any path containing an unclosed '{', validation should fail.
391        #[test]
392        fn prop_unclosed_brace_rejected(
393            // Use a valid prefix without double slashes
394            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            // Create a path with an unclosed brace
398            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        /// Property: Paths with unmatched closing braces are rejected
410        /// 
411        /// For any path containing a '}' without a matching '{', validation should fail.
412        #[test]
413        fn prop_unmatched_closing_brace_rejected(
414            // Use a valid prefix without double slashes
415            prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
416            suffix in "[a-zA-Z0-9_]{0,5}",
417        ) {
418            // Create a path with an unmatched closing brace
419            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        /// Property: Paths with empty parameter names are rejected
431        /// 
432        /// For any path containing '{}', validation should fail with EmptyParameterName.
433        #[test]
434        fn prop_empty_parameter_rejected(
435            // Use a valid prefix without double slashes
436            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            // Create a path with an empty parameter
441            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        /// Property: Paths with parameters starting with digits are rejected
458        /// 
459        /// For any path containing a parameter that starts with a digit,
460        /// validation should fail with ParameterStartsWithDigit.
461        #[test]
462        fn prop_parameter_starting_with_digit_rejected(
463            // Use a valid prefix without double slashes
464            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            // Create a path with a parameter starting with a digit
469            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        /// Property: Paths with double slashes are rejected
481        /// 
482        /// For any path containing '//', validation should fail with EmptySegment.
483        #[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            // Create a path with double slash
489            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        /// Property: Error messages contain the original path
501        /// 
502        /// For any invalid path, the error message should contain the original path
503        /// for debugging purposes.
504        #[test]
505        fn prop_error_contains_path(
506            // Generate various invalid paths
507            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(), // Missing leading slash
512                1 => format!("/{}//test", content), // Double slash
513                2 => format!("/{}/{{", content), // Unclosed brace
514                3 => format!("/{}/{{}}", content), // Empty parameter
515                4 => format!("/{}/{{1{content}}}", content = content), // Parameter starts with digit
516                _ => 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}