elif_http/routing/
extraction.rs

1//! Parameter extraction engine for elif.rs
2//!
3//! This module provides enhanced parameter extraction with type conversion,
4//! validation, and error handling for route parameters.
5//!
6//! ## Performance Design
7//!
8//! The extraction system is designed for optimal performance in request handling:
9//! 
10//! 1. **One-time validation**: Parameters are validated once during `ExtractedParams` 
11//!    creation, not on every access.
12//! 2. **Zero-copy access**: `get_str()` returns `&str` references without allocation.
13//! 3. **Efficient type conversion**: Only string parsing overhead, no constraint re-checking.
14//!
15//! ## Usage Pattern
16//!
17//! ```rust,ignore
18//! // 1. Route matcher extracts raw parameters (once per request)
19//! let route_match = matcher.resolve(&method, path)?;
20//!
21//! // 2. Parameter extractor validates constraints (once per request)  
22//! let extractor = extractors.get(&route_match.route_id)?;
23//! let params = extractor.extract_from_params(route_match.params)?;
24//!
25//! // 3. Multiple fast parameter accesses (no validation overhead)
26//! let user_id: i64 = params.get("id")?;        // Fast: only string->int parsing
27//! let user_name = params.get_str("name")?;     // Fast: zero-copy &str access
28//! ```
29
30use super::pattern::{ParamConstraint, RoutePattern};
31use std::collections::HashMap;
32use std::str::FromStr;
33use thiserror::Error;
34use uuid::Uuid;
35
36/// Errors that can occur during parameter extraction and conversion
37#[derive(Error, Debug)]
38pub enum ExtractionError {
39    #[error("Missing parameter: {0}")]
40    Missing(String),
41    #[error("Parameter validation failed for '{param}': {reason}")]
42    ValidationFailed { param: String, reason: String },
43    #[error("Type conversion failed for parameter '{param}': {error}")]
44    ConversionFailed { param: String, error: String },
45    #[error("Constraint violation for parameter '{param}': expected {constraint}, got '{value}'")]
46    ConstraintViolation { param: String, constraint: String, value: String },
47}
48
49/// Extracted and validated route parameters
50#[derive(Debug, Clone)]
51pub struct ExtractedParams {
52    raw_params: HashMap<String, String>,
53    pattern: RoutePattern,
54}
55
56impl ExtractedParams {
57    /// Create new extracted parameters from RouteMatch with validation
58    pub fn from_route_match(
59        raw_params: HashMap<String, String>, 
60        pattern: RoutePattern
61    ) -> Result<Self, ExtractionError> {
62        let extracted = Self { raw_params, pattern };
63        
64        // Validate all parameters against their constraints upfront
65        extracted.validate_all()?;
66        
67        Ok(extracted)
68    }
69
70
71    /// Get a parameter as a raw string
72    pub fn get_str(&self, name: &str) -> Option<&str> {
73        self.raw_params.get(name).map(|s| s.as_str())
74    }
75
76    /// Get a parameter converted to a specific type
77    /// 
78    /// Note: Parameters are pre-validated during ExtractedParams creation,
79    /// so this method only handles type conversion.
80    pub fn get<T>(&self, name: &str) -> Result<T, ExtractionError>
81    where
82        T: FromStr,
83        T::Err: std::fmt::Display,
84    {
85        let value = self.raw_params
86            .get(name)
87            .ok_or_else(|| ExtractionError::Missing(name.to_string()))?;
88
89        // Convert to target type (constraints already validated during creation)
90        value.parse::<T>().map_err(|e| ExtractionError::ConversionFailed {
91            param: name.to_string(),
92            error: e.to_string(),
93        })
94    }
95
96    /// Get a parameter as an integer
97    pub fn get_int(&self, name: &str) -> Result<i64, ExtractionError> {
98        self.get::<i64>(name)
99    }
100
101    /// Get a parameter as a UUID
102    pub fn get_uuid(&self, name: &str) -> Result<Uuid, ExtractionError> {
103        self.get::<Uuid>(name)
104    }
105
106    /// Get a parameter with a default value if missing
107    pub fn get_or<T>(&self, name: &str, default: T) -> Result<T, ExtractionError>
108    where
109        T: FromStr,
110        T::Err: std::fmt::Display,
111    {
112        match self.get(name) {
113            Ok(value) => Ok(value),
114            Err(ExtractionError::Missing(_)) => Ok(default),
115            Err(e) => Err(e),
116        }
117    }
118
119    /// Get a parameter as an Option (None if missing)
120    pub fn get_optional<T>(&self, name: &str) -> Result<Option<T>, ExtractionError>
121    where
122        T: FromStr,
123        T::Err: std::fmt::Display,
124    {
125        match self.get(name) {
126            Ok(value) => Ok(Some(value)),
127            Err(ExtractionError::Missing(_)) => Ok(None),
128            Err(e) => Err(e),
129        }
130    }
131
132    /// Get all parameter names
133    pub fn param_names(&self) -> Vec<&String> {
134        self.raw_params.keys().collect()
135    }
136
137    /// Get all raw parameter values
138    pub fn raw_params(&self) -> &HashMap<String, String> {
139        &self.raw_params
140    }
141
142    /// Validate all parameters against their constraints
143    pub fn validate_all(&self) -> Result<(), ExtractionError> {
144        for (param_name, param_value) in &self.raw_params {
145            // Find the corresponding segment in the pattern
146            for segment in &self.pattern.segments {
147                match segment {
148                    super::pattern::PathSegment::Parameter { name, constraint } if name == param_name => {
149                        if !constraint.validate(param_value) {
150                            return Err(ExtractionError::ConstraintViolation {
151                                param: param_name.clone(),
152                                constraint: format!("{:?}", constraint),
153                                value: param_value.clone(),
154                            });
155                        }
156                        break;
157                    }
158                    super::pattern::PathSegment::CatchAll { name } if name == param_name => {
159                        // Catch-all parameters don't have constraints to validate
160                        break;
161                    }
162                    _ => continue,
163                }
164            }
165        }
166        Ok(())
167    }
168
169}
170
171/// Parameter extractor for route patterns
172#[derive(Debug)]
173pub struct ParameterExtractor {
174    pattern: RoutePattern,
175}
176
177impl ParameterExtractor {
178    /// Create a new parameter extractor for a route pattern
179    pub fn new(pattern: RoutePattern) -> Self {
180        Self { pattern }
181    }
182
183    /// Extract and validate parameters from raw parameters (efficient - no re-matching)
184    pub fn extract_from_params(&self, raw_params: HashMap<String, String>) -> Result<ExtractedParams, ExtractionError> {
185        ExtractedParams::from_route_match(raw_params, self.pattern.clone())
186    }
187
188    /// Extract parameters from a path that matches this pattern (legacy method - less efficient)
189    /// 
190    /// Note: This method performs pattern matching and parameter extraction.
191    /// For better performance, use extract_from_params() when raw parameters are already available.
192    pub fn extract(&self, path: &str) -> Result<ExtractedParams, ExtractionError> {
193        // First verify the path matches the pattern
194        if !self.pattern.matches(path) {
195            return Err(ExtractionError::ValidationFailed {
196                param: "path".to_string(),
197                reason: "Path does not match route pattern".to_string(),
198            });
199        }
200
201        // Extract raw parameters
202        let raw_params = self.pattern.extract_params(path);
203        
204        // Use the efficient method
205        self.extract_from_params(raw_params)
206    }
207
208    /// Get the route pattern
209    pub fn pattern(&self) -> &RoutePattern {
210        &self.pattern
211    }
212
213    /// Get expected parameter names
214    pub fn param_names(&self) -> &[String] {
215        &self.pattern.param_names
216    }
217}
218
219/// Builder for creating typed parameter extractors with validation
220#[derive(Debug)]
221pub struct TypedExtractorBuilder {
222    pattern: RoutePattern,
223    custom_constraints: HashMap<String, ParamConstraint>,
224}
225
226impl TypedExtractorBuilder {
227    /// Create a new builder for the given route pattern
228    pub fn new(pattern: RoutePattern) -> Self {
229        Self {
230            pattern,
231            custom_constraints: HashMap::new(),
232        }
233    }
234
235    /// Add a custom constraint for a parameter
236    pub fn constraint(mut self, param_name: &str, constraint: ParamConstraint) -> Self {
237        self.custom_constraints.insert(param_name.to_string(), constraint);
238        self
239    }
240
241    /// Add an integer constraint
242    pub fn int_param(self, param_name: &str) -> Self {
243        self.constraint(param_name, ParamConstraint::Int)
244    }
245
246    /// Add a UUID constraint
247    pub fn uuid_param(self, param_name: &str) -> Self {
248        self.constraint(param_name, ParamConstraint::Uuid)
249    }
250
251    /// Add an alphabetic constraint
252    pub fn alpha_param(self, param_name: &str) -> Self {
253        self.constraint(param_name, ParamConstraint::Alpha)
254    }
255
256    /// Add a slug constraint
257    pub fn slug_param(self, param_name: &str) -> Self {
258        self.constraint(param_name, ParamConstraint::Slug)
259    }
260
261    /// Build the parameter extractor
262    pub fn build(mut self) -> ParameterExtractor {
263        // Apply custom constraints to the pattern
264        for segment in &mut self.pattern.segments {
265            if let super::pattern::PathSegment::Parameter { name, constraint } = segment {
266                if let Some(custom_constraint) = self.custom_constraints.remove(name) {
267                    *constraint = custom_constraint;
268                }
269            }
270        }
271
272        ParameterExtractor::new(self.pattern)
273    }
274}
275
276/// Convenience macros for parameter extraction
277#[macro_export]
278macro_rules! extract_params {
279    ($extracted:expr, $($name:ident: $type:ty),+ $(,)?) => {
280        {
281            $(
282                let $name: $type = $extracted.get(stringify!($name))?;
283            )+
284        }
285    };
286}
287
288#[macro_export]
289macro_rules! extract_optional_params {
290    ($extracted:expr, $($name:ident: $type:ty),+ $(,)?) => {
291        {
292            $(
293                let $name: Option<$type> = $extracted.get_optional(stringify!($name))?;
294            )+
295        }
296    };
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use super::super::pattern::RoutePattern;
303
304    #[test]
305    fn test_basic_parameter_extraction() {
306        let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
307        let extractor = ParameterExtractor::new(pattern);
308        
309        let extracted = extractor.extract("/users/123/posts/hello-world").unwrap();
310        
311        assert_eq!(extracted.get_str("id"), Some("123"));
312        assert_eq!(extracted.get_str("slug"), Some("hello-world"));
313    }
314
315    #[test]
316    fn test_efficient_parameter_extraction() {
317        // Test the new efficient extraction method that avoids re-matching
318        let pattern = RoutePattern::parse("/users/{id:int}/posts/{slug}").unwrap();
319        let extractor = ParameterExtractor::new(pattern);
320        
321        // Simulate what RouteMatcher would provide
322        let mut raw_params = HashMap::new();
323        raw_params.insert("id".to_string(), "456".to_string());
324        raw_params.insert("slug".to_string(), "test-post".to_string());
325        
326        // Use the efficient method (no path matching/extraction)
327        let extracted = extractor.extract_from_params(raw_params).unwrap();
328        
329        assert_eq!(extracted.get_str("id"), Some("456"));
330        assert_eq!(extracted.get_str("slug"), Some("test-post"));
331        assert_eq!(extracted.get_int("id").unwrap(), 456);
332    }
333
334    #[test]
335    fn test_typed_parameter_extraction() {
336        let pattern = RoutePattern::parse("/users/{id:int}/posts/{slug}").unwrap();
337        let extractor = ParameterExtractor::new(pattern);
338        
339        let extracted = extractor.extract("/users/123/posts/hello-world").unwrap();
340        
341        // Should extract as integer
342        assert_eq!(extracted.get_int("id").unwrap(), 123);
343        
344        // Should extract as string
345        assert_eq!(extracted.get::<String>("slug").unwrap(), "hello-world");
346    }
347
348    #[test]
349    fn test_uuid_parameter_extraction() {
350        let pattern = RoutePattern::parse("/users/{id:uuid}").unwrap();
351        let extractor = ParameterExtractor::new(pattern);
352        
353        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
354        let extracted = extractor.extract(&format!("/users/{}", uuid_str)).unwrap();
355        
356        let uuid = extracted.get_uuid("id").unwrap();
357        assert_eq!(uuid.to_string(), uuid_str);
358    }
359
360    #[test]
361    fn test_constraint_violations() {
362        let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
363        let extractor = ParameterExtractor::new(pattern);
364        
365        // Should fail with non-integer value
366        let result = extractor.extract("/users/abc");
367        assert!(result.is_err());
368        assert!(matches!(result.unwrap_err(), ExtractionError::ValidationFailed { .. }));
369    }
370
371    #[test]
372    fn test_optional_parameters() {
373        let pattern = RoutePattern::parse("/users/{id}").unwrap();
374        let extractor = ParameterExtractor::new(pattern);
375        
376        let extracted = extractor.extract("/users/123").unwrap();
377        
378        // Existing parameter
379        let id: Option<i64> = extracted.get_optional("id").unwrap();
380        assert_eq!(id, Some(123));
381        
382        // Missing parameter
383        let missing: Option<String> = extracted.get_optional("missing").unwrap();
384        assert_eq!(missing, None);
385    }
386
387    #[test]
388    fn test_parameter_with_defaults() {
389        let pattern = RoutePattern::parse("/users/{id}").unwrap();
390        let extractor = ParameterExtractor::new(pattern);
391        
392        let extracted = extractor.extract("/users/123").unwrap();
393        
394        // Existing parameter
395        let id = extracted.get_or("id", 0i64).unwrap();
396        assert_eq!(id, 123);
397        
398        // Missing parameter with default
399        let page = extracted.get_or("page", 1i64).unwrap();
400        assert_eq!(page, 1);
401    }
402
403    #[test]
404    fn test_catch_all_parameter() {
405        let pattern = RoutePattern::parse("/files/*path").unwrap();
406        let extractor = ParameterExtractor::new(pattern);
407        
408        let extracted = extractor.extract("/files/docs/images/logo.png").unwrap();
409        
410        let path: String = extracted.get("path").unwrap();
411        assert_eq!(path, "docs/images/logo.png");
412    }
413
414    #[test]
415    fn test_typed_extractor_builder() {
416        let pattern = RoutePattern::parse("/api/{version}/users/{id}").unwrap();
417        let extractor = TypedExtractorBuilder::new(pattern)
418            .slug_param("version")
419            .int_param("id")
420            .build();
421        
422        let extracted = extractor.extract("/api/v1/users/123").unwrap();
423        
424        assert_eq!(extracted.get::<String>("version").unwrap(), "v1");
425        assert_eq!(extracted.get_int("id").unwrap(), 123);
426    }
427
428    #[test]
429    fn test_custom_regex_constraint() {
430        use regex::Regex;
431        
432        let pattern = RoutePattern::parse("/posts/{slug}").unwrap();
433        let regex = Regex::new(r"^[a-z0-9-]+$").unwrap();
434        
435        let extractor = TypedExtractorBuilder::new(pattern)
436            .constraint("slug", ParamConstraint::Custom(regex))
437            .build();
438        
439        // Should match valid slug
440        let result = extractor.extract("/posts/hello-world-123");
441        assert!(result.is_ok());
442        
443        // Should fail with invalid characters
444        let result = extractor.extract("/posts/Hello_World!");
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_all_constraints() {
450        // Test all built-in constraint types
451        assert!(ParamConstraint::Int.validate("123"));
452        assert!(!ParamConstraint::Int.validate("abc"));
453        
454        assert!(ParamConstraint::Alpha.validate("hello"));
455        assert!(!ParamConstraint::Alpha.validate("hello123"));
456        
457        assert!(ParamConstraint::Slug.validate("hello-world_123"));
458        assert!(!ParamConstraint::Slug.validate("hello world!"));
459        
460        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
461        assert!(ParamConstraint::Uuid.validate(uuid_str));
462        assert!(!ParamConstraint::Uuid.validate("not-a-uuid"));
463        
464        assert!(ParamConstraint::None.validate("anything"));
465        assert!(!ParamConstraint::None.validate("")); // Empty not allowed
466    }
467
468    #[test]
469    fn test_error_types() {
470        let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
471        let extractor = ParameterExtractor::new(pattern);
472        
473        let extracted = extractor.extract("/users/123").unwrap();
474        
475        // Missing parameter error
476        let result: Result<i64, _> = extracted.get("missing");
477        assert!(matches!(result.unwrap_err(), ExtractionError::Missing(_)));
478        
479        // Type conversion error (try to get string as different type)
480        // First we need an actual string parameter
481        let pattern2 = RoutePattern::parse("/users/{name}").unwrap();
482        let extractor2 = ParameterExtractor::new(pattern2);
483        let extracted2 = extractor2.extract("/users/john").unwrap();
484        
485        let result: Result<i64, _> = extracted2.get("name");
486        assert!(matches!(result.unwrap_err(), ExtractionError::ConversionFailed { .. }));
487    }
488
489    #[test]
490    fn test_parameter_access_performance() {
491        // Test that parameter access is fast (no redundant validation)
492        let pattern = RoutePattern::parse("/users/{id:int}/posts/{slug:alpha}").unwrap();
493        
494        let mut raw_params = HashMap::new();
495        raw_params.insert("id".to_string(), "123".to_string());
496        raw_params.insert("slug".to_string(), "helloworld".to_string());
497        
498        let extracted = ExtractedParams::from_route_match(raw_params, pattern).unwrap();
499        
500        let start = std::time::Instant::now();
501        
502        // Perform many parameter accesses
503        for _ in 0..10000 {
504            let id: i64 = extracted.get("id").unwrap();
505            let slug: String = extracted.get("slug").unwrap();
506            assert_eq!(id, 123);
507            assert_eq!(slug, "helloworld");
508        }
509        
510        let elapsed = start.elapsed();
511        
512        // Should be very fast since no constraint validation is done on each access
513        assert!(elapsed.as_millis() < 50, "Parameter access took too long: {}ms", elapsed.as_millis());
514        
515        println!("20,000 parameter accesses completed in {}μs", elapsed.as_micros());
516    }
517
518    #[test]
519    fn test_integration_with_route_matcher() {
520        // Test the complete flow: RouteMatcher -> ParameterExtractor (efficient)
521        use super::super::{HttpMethod, compiler::RouteCompilerBuilder};
522        
523        // Build a compiled routing system
524        let compilation_result = RouteCompilerBuilder::new()
525            .get("users_show".to_string(), "/users/{id:int}".to_string())
526            .get("posts_show".to_string(), "/posts/{slug:alpha}/comments/{id:uuid}".to_string())
527            .build()
528            .unwrap();
529        
530        // Simulate a request resolution
531        let route_match = compilation_result.matcher
532            .resolve(&HttpMethod::GET, "/users/123")
533            .unwrap();
534        
535        assert_eq!(route_match.route_id, "users_show");
536        assert_eq!(route_match.params.get("id"), Some(&"123".to_string()));
537        
538        // Use the efficient extraction method (no re-matching!)
539        let extractor = compilation_result.extractors.get("users_show").unwrap();
540        let extracted = extractor.extract_from_params(route_match.params).unwrap();
541        
542        // Type-safe parameter access
543        let user_id: i64 = extracted.get("id").unwrap();
544        assert_eq!(user_id, 123);
545        
546        // Test complex route with multiple constraints
547        // Use "helloworld" (no hyphens) to match the alpha constraint
548        let route_match = compilation_result.matcher
549            .resolve(&HttpMethod::GET, "/posts/helloworld/comments/550e8400-e29b-41d4-a716-446655440000")
550            .unwrap();
551        
552        assert_eq!(route_match.route_id, "posts_show");
553        
554        let extractor = compilation_result.extractors.get("posts_show").unwrap();
555        let extracted = extractor.extract_from_params(route_match.params).unwrap();
556        
557        let slug: String = extracted.get("slug").unwrap();
558        let comment_id = extracted.get_uuid("id").unwrap();
559        
560        assert_eq!(slug, "helloworld");
561        assert_eq!(comment_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
562    }
563}