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