spikard_core/
parameters.rs

1//! Parameter validation using JSON Schema
2//!
3//! This module provides validation for request parameters (query, path, header, cookie)
4//! using JSON Schema as the validation contract.
5
6use crate::debug_log_module;
7use crate::validation::{ValidationError, ValidationErrorDetail};
8use serde_json::{Value, json};
9use std::collections::HashMap;
10
11/// Parameter source - where the parameter comes from
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ParameterSource {
14    Query,
15    Path,
16    Header,
17    Cookie,
18}
19
20impl ParameterSource {
21    fn from_str(s: &str) -> Option<Self> {
22        match s {
23            "query" => Some(Self::Query),
24            "path" => Some(Self::Path),
25            "header" => Some(Self::Header),
26            "cookie" => Some(Self::Cookie),
27            _ => None,
28        }
29    }
30}
31
32/// Parameter definition extracted from schema
33#[derive(Debug, Clone)]
34struct ParameterDef {
35    name: String,
36    source: ParameterSource,
37    expected_type: Option<String>,
38    format: Option<String>,
39    required: bool,
40}
41
42/// Parameter validator that uses JSON Schema
43#[derive(Clone)]
44pub struct ParameterValidator {
45    schema: Value,
46    parameter_defs: Vec<ParameterDef>,
47}
48
49impl ParameterValidator {
50    /// Create a new parameter validator from a JSON Schema
51    ///
52    /// The schema should describe all parameters with their types and constraints.
53    /// Each property MUST have a "source" field indicating where the parameter comes from.
54    pub fn new(schema: Value) -> Result<Self, String> {
55        let parameter_defs = Self::extract_parameter_defs(&schema)?;
56
57        Ok(Self { schema, parameter_defs })
58    }
59
60    /// Extract parameter definitions from the schema
61    fn extract_parameter_defs(schema: &Value) -> Result<Vec<ParameterDef>, String> {
62        let mut defs = Vec::new();
63
64        let properties = schema.get("properties").and_then(|p| p.as_object()).ok_or_else(|| {
65            anyhow::anyhow!("Parameter schema validation failed")
66                .context("Schema must have 'properties' object")
67                .to_string()
68        })?;
69
70        let required_list = schema
71            .get("required")
72            .and_then(|r| r.as_array())
73            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
74            .unwrap_or_default();
75
76        for (name, prop) in properties {
77            let source_str = prop.get("source").and_then(|s| s.as_str()).ok_or_else(|| {
78                anyhow::anyhow!("Invalid parameter schema")
79                    .context(format!("Parameter '{}' missing required 'source' field", name))
80                    .to_string()
81            })?;
82
83            let source = ParameterSource::from_str(source_str).ok_or_else(|| {
84                anyhow::anyhow!("Invalid parameter schema")
85                    .context(format!(
86                        "Invalid source '{}' for parameter '{}' (expected: query, path, header, or cookie)",
87                        source_str, name
88                    ))
89                    .to_string()
90            })?;
91
92            let expected_type = prop.get("type").and_then(|t| t.as_str()).map(String::from);
93            let format = prop.get("format").and_then(|f| f.as_str()).map(String::from);
94
95            let is_optional = prop.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
96            let required = required_list.contains(&name.as_str()) && !is_optional;
97
98            defs.push(ParameterDef {
99                name: name.clone(),
100                source,
101                expected_type,
102                format,
103                required,
104            });
105        }
106
107        Ok(defs)
108    }
109
110    /// Get the underlying JSON Schema
111    pub fn schema(&self) -> &Value {
112        &self.schema
113    }
114
115    /// Validate and extract parameters from the request
116    ///
117    /// This builds a JSON object from query/path/header/cookie params and validates it.
118    /// It performs type coercion (e.g., "123" → 123) based on the schema.
119    ///
120    /// Returns the validated JSON object that can be directly converted to Python kwargs.
121    pub fn validate_and_extract(
122        &self,
123        query_params: &Value,
124        raw_query_params: &HashMap<String, String>,
125        path_params: &HashMap<String, String>,
126        headers: &HashMap<String, String>,
127        cookies: &HashMap<String, String>,
128    ) -> Result<Value, ValidationError> {
129        tracing::debug!(
130            "validate_and_extract called with query_params: {:?}, path_params: {:?}, headers: {} items, cookies: {} items",
131            query_params,
132            path_params,
133            headers.len(),
134            cookies.len()
135        );
136        tracing::debug!("parameter_defs count: {}", self.parameter_defs.len());
137
138        let mut params_map = serde_json::Map::new();
139        let mut errors = Vec::new();
140        let mut raw_values_map: HashMap<String, String> = HashMap::new();
141
142        for param_def in &self.parameter_defs {
143            tracing::debug!(
144                "Processing param: {:?}, source: {:?}, required: {}, expected_type: {:?}",
145                param_def.name,
146                param_def.source,
147                param_def.required,
148                param_def.expected_type
149            );
150
151            if param_def.source == ParameterSource::Query && param_def.expected_type.as_deref() == Some("array") {
152                let query_value = query_params.get(&param_def.name);
153
154                if param_def.required && query_value.is_none() {
155                    errors.push(ValidationErrorDetail {
156                        error_type: "missing".to_string(),
157                        loc: vec!["query".to_string(), param_def.name.clone()],
158                        msg: "Field required".to_string(),
159                        input: Value::Null,
160                        ctx: None,
161                    });
162                    continue;
163                }
164
165                if let Some(value) = query_value {
166                    let array_value = if value.is_array() {
167                        value.clone()
168                    } else {
169                        Value::Array(vec![value.clone()])
170                    };
171                    params_map.insert(param_def.name.clone(), array_value);
172                }
173                continue;
174            }
175
176            let raw_value_string = match param_def.source {
177                ParameterSource::Query => raw_query_params.get(&param_def.name),
178                ParameterSource::Path => path_params.get(&param_def.name),
179                ParameterSource::Header => {
180                    let header_name = param_def.name.replace('_', "-").to_lowercase();
181                    headers.get(&header_name)
182                }
183                ParameterSource::Cookie => cookies.get(&param_def.name),
184            };
185
186            tracing::debug!("raw_value_string for {}: {:?}", param_def.name, raw_value_string);
187
188            if param_def.required && raw_value_string.is_none() {
189                let source_str = match param_def.source {
190                    ParameterSource::Query => "query",
191                    ParameterSource::Path => "path",
192                    ParameterSource::Header => "headers",
193                    ParameterSource::Cookie => "cookie",
194                };
195                let param_name_for_error = if param_def.source == ParameterSource::Header {
196                    param_def.name.replace('_', "-").to_lowercase()
197                } else {
198                    param_def.name.clone()
199                };
200                errors.push(ValidationErrorDetail {
201                    error_type: "missing".to_string(),
202                    loc: vec![source_str.to_string(), param_name_for_error],
203                    msg: "Field required".to_string(),
204                    input: Value::Null,
205                    ctx: None,
206                });
207                continue;
208            }
209
210            if let Some(value_str) = raw_value_string {
211                tracing::debug!(
212                    "Coercing value '{}' to type {:?} with format {:?}",
213                    value_str,
214                    param_def.expected_type,
215                    param_def.format
216                );
217                match Self::coerce_value(
218                    value_str,
219                    param_def.expected_type.as_deref(),
220                    param_def.format.as_deref(),
221                ) {
222                    Ok(coerced) => {
223                        tracing::debug!("Coerced to: {:?}", coerced);
224                        params_map.insert(param_def.name.clone(), coerced);
225                        raw_values_map.insert(param_def.name.clone(), value_str.clone());
226                    }
227                    Err(e) => {
228                        tracing::debug!("Coercion failed: {}", e);
229                        let source_str = match param_def.source {
230                            ParameterSource::Query => "query",
231                            ParameterSource::Path => "path",
232                            ParameterSource::Header => "headers",
233                            ParameterSource::Cookie => "cookie",
234                        };
235                        let (error_type, error_msg) =
236                            match (param_def.expected_type.as_deref(), param_def.format.as_deref()) {
237                                (Some("integer"), _) => (
238                                    "int_parsing",
239                                    "Input should be a valid integer, unable to parse string as an integer".to_string(),
240                                ),
241                                (Some("number"), _) => (
242                                    "float_parsing",
243                                    "Input should be a valid number, unable to parse string as a number".to_string(),
244                                ),
245                                (Some("boolean"), _) => (
246                                    "bool_parsing",
247                                    "Input should be a valid boolean, unable to interpret input".to_string(),
248                                ),
249                                (Some("string"), Some("uuid")) => {
250                                    ("uuid_parsing", format!("Input should be a valid UUID, {}", e))
251                                }
252                                (Some("string"), Some("date")) => {
253                                    ("date_parsing", format!("Input should be a valid date, {}", e))
254                                }
255                                (Some("string"), Some("date-time")) => {
256                                    ("datetime_parsing", format!("Input should be a valid datetime, {}", e))
257                                }
258                                (Some("string"), Some("time")) => {
259                                    ("time_parsing", format!("Input should be a valid time, {}", e))
260                                }
261                                (Some("string"), Some("duration")) => {
262                                    ("duration_parsing", format!("Input should be a valid duration, {}", e))
263                                }
264                                _ => ("type_error", e.clone()),
265                            };
266                        let param_name_for_error = if param_def.source == ParameterSource::Header {
267                            param_def.name.replace('_', "-").to_lowercase()
268                        } else {
269                            param_def.name.clone()
270                        };
271                        errors.push(ValidationErrorDetail {
272                            error_type: error_type.to_string(),
273                            loc: vec![source_str.to_string(), param_name_for_error],
274                            msg: error_msg,
275                            input: Value::String(value_str.clone()),
276                            ctx: None,
277                        });
278                    }
279                }
280            }
281        }
282
283        if !errors.is_empty() {
284            tracing::debug!("Errors during extraction: {:?}", errors);
285            return Err(ValidationError { errors });
286        }
287
288        let params_json = Value::Object(params_map.clone());
289        tracing::debug!("params_json after coercion: {:?}", params_json);
290
291        let validation_schema = self.create_validation_schema();
292        tracing::debug!("validation_schema: {:?}", validation_schema);
293
294        let validator = crate::validation::SchemaValidator::new(validation_schema).map_err(|e| ValidationError {
295            errors: vec![ValidationErrorDetail {
296                error_type: "schema_error".to_string(),
297                loc: vec!["schema".to_string()],
298                msg: e,
299                input: Value::Null,
300                ctx: None,
301            }],
302        })?;
303
304        tracing::debug!("About to validate params_json against schema");
305        tracing::debug!("params_json = {:?}", params_json);
306        tracing::debug!(
307            "params_json pretty = {}",
308            serde_json::to_string_pretty(&params_json).unwrap_or_default()
309        );
310        tracing::debug!(
311            "schema = {}",
312            serde_json::to_string_pretty(&self.schema).unwrap_or_default()
313        );
314        match validator.validate(&params_json) {
315            Ok(_) => {
316                tracing::debug!("Validation succeeded");
317                Ok(params_json)
318            }
319            Err(mut validation_err) => {
320                tracing::debug!("Validation failed: {:?}", validation_err);
321
322                for error in &mut validation_err.errors {
323                    if error.loc.len() >= 2 && error.loc[0] == "body" {
324                        let param_name = &error.loc[1];
325                        if let Some(param_def) = self.parameter_defs.iter().find(|p| &p.name == param_name) {
326                            let source_str = match param_def.source {
327                                ParameterSource::Query => "query",
328                                ParameterSource::Path => "path",
329                                ParameterSource::Header => "headers",
330                                ParameterSource::Cookie => "cookie",
331                            };
332                            error.loc[0] = source_str.to_string();
333
334                            if param_def.source == ParameterSource::Header {
335                                error.loc[1] = param_def.name.replace('_', "-").to_lowercase();
336                            }
337
338                            if let Some(raw_value) = raw_values_map.get(&param_def.name) {
339                                error.input = Value::String(raw_value.clone());
340                            }
341                        }
342                    }
343                }
344
345                debug_log_module!(
346                    "parameters",
347                    "Returning {} validation errors",
348                    validation_err.errors.len()
349                );
350                for (i, error) in validation_err.errors.iter().enumerate() {
351                    debug_log_module!(
352                        "parameters",
353                        "  Error {}: type={}, loc={:?}, msg={}, input={}, ctx={:?}",
354                        i,
355                        error.error_type,
356                        error.loc,
357                        error.msg,
358                        error.input,
359                        error.ctx
360                    );
361                }
362                #[allow(clippy::collapsible_if)]
363                if crate::debug::is_enabled() {
364                    if let Ok(json_errors) = serde_json::to_value(&validation_err.errors) {
365                        if let Ok(json_str) = serde_json::to_string_pretty(&json_errors) {
366                            debug_log_module!("parameters", "Serialized errors:\n{}", json_str);
367                        }
368                    }
369                }
370
371                Err(validation_err)
372            }
373        }
374    }
375
376    /// Coerce a string value to the expected JSON type
377    fn coerce_value(value: &str, expected_type: Option<&str>, format: Option<&str>) -> Result<Value, String> {
378        if let Some(fmt) = format {
379            match fmt {
380                "uuid" => {
381                    Self::validate_uuid_format(value)?;
382                    return Ok(json!(value));
383                }
384                "date" => {
385                    Self::validate_date_format(value)?;
386                    return Ok(json!(value));
387                }
388                "date-time" => {
389                    Self::validate_datetime_format(value)?;
390                    return Ok(json!(value));
391                }
392                "time" => {
393                    Self::validate_time_format(value)?;
394                    return Ok(json!(value));
395                }
396                "duration" => {
397                    Self::validate_duration_format(value)?;
398                    return Ok(json!(value));
399                }
400                _ => {}
401            }
402        }
403
404        match expected_type {
405            Some("integer") => value
406                .parse::<i64>()
407                .map(|i| json!(i))
408                .map_err(|e| format!("Invalid integer: {}", e)),
409            Some("number") => value
410                .parse::<f64>()
411                .map(|f| json!(f))
412                .map_err(|e| format!("Invalid number: {}", e)),
413            Some("boolean") => {
414                if value.is_empty() {
415                    return Ok(json!(false));
416                }
417                let value_lower = value.to_lowercase();
418                if value_lower == "true" || value == "1" {
419                    Ok(json!(true))
420                } else if value_lower == "false" || value == "0" {
421                    Ok(json!(false))
422                } else {
423                    Err(format!("Invalid boolean: {}", value))
424                }
425            }
426            _ => Ok(json!(value)),
427        }
428    }
429
430    /// Validate ISO 8601 date format: YYYY-MM-DD
431    fn validate_date_format(value: &str) -> Result<(), String> {
432        jiff::civil::Date::strptime("%Y-%m-%d", value)
433            .map(|_| ())
434            .map_err(|e| format!("Invalid date format: {}", e))
435    }
436
437    /// Validate ISO 8601 datetime format
438    fn validate_datetime_format(value: &str) -> Result<(), String> {
439        use std::str::FromStr;
440        jiff::Timestamp::from_str(value)
441            .map(|_| ())
442            .map_err(|e| format!("Invalid datetime format: {}", e))
443    }
444
445    /// Validate ISO 8601 time format: HH:MM:SS or HH:MM:SS.ffffff
446    fn validate_time_format(value: &str) -> Result<(), String> {
447        jiff::civil::Time::strptime("%H:%M:%S", value)
448            .or_else(|_| jiff::civil::Time::strptime("%H:%M", value))
449            .map(|_| ())
450            .map_err(|e| format!("Invalid time format: {}", e))
451    }
452
453    /// Validate duration format (simplified - accept ISO 8601 duration or simple formats)
454    fn validate_duration_format(value: &str) -> Result<(), String> {
455        use std::str::FromStr;
456        jiff::Span::from_str(value)
457            .map(|_| ())
458            .map_err(|e| format!("Invalid duration format: {}", e))
459    }
460
461    /// Validate UUID format
462    fn validate_uuid_format(value: &str) -> Result<(), String> {
463        use std::str::FromStr;
464        uuid::Uuid::from_str(value)
465            .map(|_| ())
466            .map_err(|_e| format!("invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `{}` at {}",
467                value.chars().next().unwrap_or('?'),
468                value.chars().position(|c| !c.is_ascii_hexdigit() && c != '-').unwrap_or(0)))
469    }
470
471    /// Create a validation schema without the "source" fields
472    /// (JSON Schema doesn't recognize "source" as a standard field)
473    fn create_validation_schema(&self) -> Value {
474        let mut schema = self.schema.clone();
475
476        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
477            for (_name, prop) in properties.iter_mut() {
478                if let Some(obj) = prop.as_object_mut() {
479                    obj.remove("source");
480                }
481            }
482        }
483
484        schema
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use serde_json::json;
492
493    #[test]
494    fn test_array_query_parameter() {
495        let schema = json!({
496            "type": "object",
497            "properties": {
498                "device_ids": {
499                    "type": "array",
500                    "items": {"type": "integer"},
501                    "source": "query"
502                }
503            },
504            "required": []
505        });
506
507        let validator = ParameterValidator::new(schema).unwrap();
508
509        let query_params = json!({
510            "device_ids": [1, 2]
511        });
512        let raw_query_params = HashMap::new();
513        let path_params = HashMap::new();
514
515        let result = validator.validate_and_extract(
516            &query_params,
517            &raw_query_params,
518            &path_params,
519            &HashMap::new(),
520            &HashMap::new(),
521        );
522        assert!(
523            result.is_ok(),
524            "Array query param validation failed: {:?}",
525            result.err()
526        );
527
528        let extracted = result.unwrap();
529        assert_eq!(extracted["device_ids"], json!([1, 2]));
530    }
531
532    #[test]
533    fn test_path_parameter_extraction() {
534        let schema = json!({
535            "type": "object",
536            "properties": {
537                "item_id": {
538                    "type": "string",
539                    "source": "path"
540                }
541            },
542            "required": ["item_id"]
543        });
544
545        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
546
547        let mut path_params = HashMap::new();
548        path_params.insert("item_id".to_string(), "foobar".to_string());
549        let query_params = json!({});
550        let raw_query_params = HashMap::new();
551
552        let result = validator.validate_and_extract(
553            &query_params,
554            &raw_query_params,
555            &path_params,
556            &HashMap::new(),
557            &HashMap::new(),
558        );
559        assert!(result.is_ok(), "Validation should succeed: {:?}", result);
560
561        let params = result.unwrap();
562        assert_eq!(params, json!({"item_id": "foobar"}));
563    }
564
565    #[test]
566    fn test_boolean_path_parameter_coercion() {
567        let schema = json!({
568            "type": "object",
569            "properties": {
570                "value": {
571                    "type": "boolean",
572                    "source": "path"
573                }
574            },
575            "required": ["value"]
576        });
577
578        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
579
580        let mut path_params = HashMap::new();
581        path_params.insert("value".to_string(), "True".to_string());
582        let query_params = json!({});
583        let raw_query_params = HashMap::new();
584
585        let result = validator.validate_and_extract(
586            &query_params,
587            &raw_query_params,
588            &path_params,
589            &HashMap::new(),
590            &HashMap::new(),
591        );
592        if result.is_err() {
593            eprintln!("Error for 'True': {:?}", result);
594        }
595        assert!(result.is_ok(), "Validation should succeed for 'True': {:?}", result);
596        let params = result.unwrap();
597        assert_eq!(params, json!({"value": true}));
598
599        path_params.insert("value".to_string(), "1".to_string());
600        let query_params_1 = json!({});
601        let result = validator.validate_and_extract(
602            &query_params_1,
603            &raw_query_params,
604            &path_params,
605            &HashMap::new(),
606            &HashMap::new(),
607        );
608        assert!(result.is_ok(), "Validation should succeed for '1': {:?}", result);
609        let params = result.unwrap();
610        assert_eq!(params, json!({"value": true}));
611
612        path_params.insert("value".to_string(), "false".to_string());
613        let query_params_false = json!({});
614        let result = validator.validate_and_extract(
615            &query_params_false,
616            &raw_query_params,
617            &path_params,
618            &HashMap::new(),
619            &HashMap::new(),
620        );
621        assert!(result.is_ok(), "Validation should succeed for 'false': {:?}", result);
622        let params = result.unwrap();
623        assert_eq!(params, json!({"value": false}));
624
625        path_params.insert("value".to_string(), "TRUE".to_string());
626        let query_params_true = json!({});
627        let result = validator.validate_and_extract(
628            &query_params_true,
629            &raw_query_params,
630            &path_params,
631            &HashMap::new(),
632            &HashMap::new(),
633        );
634        assert!(result.is_ok(), "Validation should succeed for 'TRUE': {:?}", result);
635        let params = result.unwrap();
636        assert_eq!(params, json!({"value": true}));
637    }
638
639    #[test]
640    fn test_boolean_query_parameter_coercion() {
641        let schema = json!({
642            "type": "object",
643            "properties": {
644                "flag": {
645                    "type": "boolean",
646                    "source": "query"
647                }
648            },
649            "required": ["flag"]
650        });
651
652        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
653        let path_params = HashMap::new();
654
655        let mut raw_query_params = HashMap::new();
656        raw_query_params.insert("flag".to_string(), "1".to_string());
657        let query_params = json!({"flag": 1});
658        let result = validator.validate_and_extract(
659            &query_params,
660            &raw_query_params,
661            &path_params,
662            &HashMap::new(),
663            &HashMap::new(),
664        );
665        assert!(result.is_ok(), "Validation should succeed for integer 1: {:?}", result);
666        let params = result.unwrap();
667        assert_eq!(params, json!({"flag": true}));
668
669        let mut raw_query_params = HashMap::new();
670        raw_query_params.insert("flag".to_string(), "0".to_string());
671        let query_params = json!({"flag": 0});
672        let result = validator.validate_and_extract(
673            &query_params,
674            &raw_query_params,
675            &path_params,
676            &HashMap::new(),
677            &HashMap::new(),
678        );
679        assert!(result.is_ok(), "Validation should succeed for integer 0: {:?}", result);
680        let params = result.unwrap();
681        assert_eq!(params, json!({"flag": false}));
682
683        let mut raw_query_params = HashMap::new();
684        raw_query_params.insert("flag".to_string(), "true".to_string());
685        let query_params = json!({"flag": true});
686        let result = validator.validate_and_extract(
687            &query_params,
688            &raw_query_params,
689            &path_params,
690            &HashMap::new(),
691            &HashMap::new(),
692        );
693        assert!(
694            result.is_ok(),
695            "Validation should succeed for boolean true: {:?}",
696            result
697        );
698        let params = result.unwrap();
699        assert_eq!(params, json!({"flag": true}));
700
701        let mut raw_query_params = HashMap::new();
702        raw_query_params.insert("flag".to_string(), "false".to_string());
703        let query_params = json!({"flag": false});
704        let result = validator.validate_and_extract(
705            &query_params,
706            &raw_query_params,
707            &path_params,
708            &HashMap::new(),
709            &HashMap::new(),
710        );
711        assert!(
712            result.is_ok(),
713            "Validation should succeed for boolean false: {:?}",
714            result
715        );
716        let params = result.unwrap();
717        assert_eq!(params, json!({"flag": false}));
718    }
719}