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