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::validation::{SchemaValidator, ValidationError, ValidationErrorDetail};
7use serde_json::{Value, json};
8use std::collections::HashMap;
9use std::fmt;
10use std::sync::Arc;
11
12/// Parameter source - where the parameter comes from
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ParameterSource {
15    Query,
16    Path,
17    Header,
18    Cookie,
19}
20
21impl ParameterSource {
22    fn from_str(s: &str) -> Option<Self> {
23        match s {
24            "query" => Some(Self::Query),
25            "path" => Some(Self::Path),
26            "header" => Some(Self::Header),
27            "cookie" => Some(Self::Cookie),
28            _ => None,
29        }
30    }
31}
32
33/// Parameter definition extracted from schema
34#[derive(Debug, Clone)]
35struct ParameterDef {
36    name: String,
37    lookup_key: String,
38    error_key: String,
39    source: ParameterSource,
40    expected_type: Option<String>,
41    format: Option<String>,
42    required: bool,
43}
44
45#[derive(Clone)]
46struct ParameterValidatorInner {
47    schema: Value,
48    schema_validator: Option<SchemaValidator>,
49    parameter_defs: Vec<ParameterDef>,
50}
51
52/// Parameter validator that uses JSON Schema
53#[derive(Clone)]
54pub struct ParameterValidator {
55    inner: Arc<ParameterValidatorInner>,
56}
57
58impl fmt::Debug for ParameterValidator {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.debug_struct("ParameterValidator")
61            .field("schema", &self.inner.schema)
62            .field("parameter_defs_len", &self.inner.parameter_defs.len())
63            .finish()
64    }
65}
66
67impl ParameterValidator {
68    /// Create a new parameter validator from a JSON Schema
69    ///
70    /// The schema should describe all parameters with their types and constraints.
71    /// Each property MUST have a "source" field indicating where the parameter comes from.
72    pub fn new(schema: Value) -> Result<Self, String> {
73        let parameter_defs = Self::extract_parameter_defs(&schema)?;
74        let validation_schema = Self::create_validation_schema(&schema);
75        let schema_validator = if Self::requires_full_schema_validation(&validation_schema) {
76            Some(SchemaValidator::new(validation_schema)?)
77        } else {
78            None
79        };
80
81        Ok(Self {
82            inner: Arc::new(ParameterValidatorInner {
83                schema,
84                schema_validator,
85                parameter_defs,
86            }),
87        })
88    }
89
90    /// Whether this validator needs access to request headers.
91    pub fn requires_headers(&self) -> bool {
92        self.inner
93            .parameter_defs
94            .iter()
95            .any(|def| def.source == ParameterSource::Header)
96    }
97
98    /// Whether this validator needs access to request cookies.
99    pub fn requires_cookies(&self) -> bool {
100        self.inner
101            .parameter_defs
102            .iter()
103            .any(|def| def.source == ParameterSource::Cookie)
104    }
105
106    /// Whether the validator has any parameter definitions.
107    pub fn has_params(&self) -> bool {
108        !self.inner.parameter_defs.is_empty()
109    }
110
111    /// Determine whether a parameter schema needs full JSON Schema validation.
112    ///
113    /// The hot path in `validate_and_extract()` already enforces:
114    /// - required vs optional presence
115    /// - type coercion
116    /// - format parsing (uuid/date/date-time/time/duration)
117    ///
118    /// When the schema contains only structural keywords and metadata, we can skip the
119    /// (relatively expensive) jsonschema validator without changing semantics.
120    fn requires_full_schema_validation(schema: &Value) -> bool {
121        fn recurse(value: &Value) -> bool {
122            let Some(obj) = value.as_object() else {
123                return false;
124            };
125
126            for (key, child) in obj {
127                match key.as_str() {
128                    // Structural keywords we support in the coercion pass.
129                    "type" | "format" | "properties" | "required" | "items" | "additionalProperties" => {}
130
131                    // Metadata keywords which don't affect validation semantics.
132                    "title" | "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly"
133                    | "$schema" | "$id" => {}
134
135                    // Anything else may impose constraints we don't enforce manually.
136                    _ => return true,
137                }
138
139                if recurse(child) {
140                    return true;
141                }
142            }
143
144            false
145        }
146
147        recurse(schema)
148    }
149
150    /// Extract parameter definitions from the schema
151    fn extract_parameter_defs(schema: &Value) -> Result<Vec<ParameterDef>, String> {
152        let mut defs = Vec::new();
153
154        let properties = schema
155            .get("properties")
156            .and_then(|p| p.as_object())
157            .cloned()
158            .unwrap_or_default();
159
160        let required_list = schema
161            .get("required")
162            .and_then(|r| r.as_array())
163            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
164            .unwrap_or_default();
165
166        for (name, prop) in properties {
167            let source_str = prop.get("source").and_then(|s| s.as_str()).ok_or_else(|| {
168                anyhow::anyhow!("Invalid parameter schema")
169                    .context(format!("Parameter '{}' missing required 'source' field", name))
170                    .to_string()
171            })?;
172
173            let source = ParameterSource::from_str(source_str).ok_or_else(|| {
174                anyhow::anyhow!("Invalid parameter schema")
175                    .context(format!(
176                        "Invalid source '{}' for parameter '{}' (expected: query, path, header, or cookie)",
177                        source_str, name
178                    ))
179                    .to_string()
180            })?;
181
182            let expected_type = prop.get("type").and_then(|t| t.as_str()).map(String::from);
183            let format = prop.get("format").and_then(|f| f.as_str()).map(String::from);
184
185            let is_optional = prop.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
186            let required = required_list.contains(&name.as_str()) && !is_optional;
187
188            let (lookup_key, error_key) = if source == ParameterSource::Header {
189                let header_key = name.replace('_', "-").to_lowercase();
190                (header_key.clone(), header_key)
191            } else {
192                (name.clone(), name.clone())
193            };
194
195            defs.push(ParameterDef {
196                name: name.clone(),
197                lookup_key,
198                error_key,
199                source,
200                expected_type,
201                format,
202                required,
203            });
204        }
205
206        Ok(defs)
207    }
208
209    /// Get the underlying JSON Schema
210    pub fn schema(&self) -> &Value {
211        &self.inner.schema
212    }
213
214    /// Validate and extract parameters from the request
215    ///
216    /// This builds a JSON object from query/path/header/cookie params and validates it.
217    /// It performs type coercion (e.g., "123" → 123) based on the schema.
218    ///
219    /// Returns the validated JSON object that can be directly converted to Python kwargs.
220    pub fn validate_and_extract(
221        &self,
222        query_params: &Value,
223        raw_query_params: &HashMap<String, Vec<String>>,
224        path_params: &HashMap<String, String>,
225        headers: &HashMap<String, String>,
226        cookies: &HashMap<String, String>,
227    ) -> Result<Value, ValidationError> {
228        let mut params_map = serde_json::Map::new();
229        let mut errors = Vec::new();
230        for param_def in &self.inner.parameter_defs {
231            if param_def.source == ParameterSource::Query && param_def.expected_type.as_deref() == Some("array") {
232                let raw_values = raw_query_params.get(&param_def.lookup_key);
233                let query_value = query_params.get(&param_def.name);
234
235                if param_def.required && raw_values.is_none() && query_value.is_none() {
236                    errors.push(ValidationErrorDetail {
237                        error_type: "missing".to_string(),
238                        loc: vec!["query".to_string(), param_def.error_key.clone()],
239                        msg: "Field required".to_string(),
240                        input: Value::Null,
241                        ctx: None,
242                    });
243                    continue;
244                }
245
246                if let Some(values) = raw_values {
247                    let (item_type, item_format) = self.array_item_type_and_format(&param_def.name);
248                    let mut out = Vec::with_capacity(values.len());
249                    for value in values {
250                        match Self::coerce_value(value, item_type, item_format) {
251                            Ok(coerced) => out.push(coerced),
252                            Err(e) => {
253                                errors.push(ValidationErrorDetail {
254                                    error_type: match item_type {
255                                        Some("integer") => "int_parsing".to_string(),
256                                        Some("number") => "float_parsing".to_string(),
257                                        Some("boolean") => "bool_parsing".to_string(),
258                                        Some("string") => match item_format {
259                                            Some("uuid") => "uuid_parsing".to_string(),
260                                            Some("date") => "date_parsing".to_string(),
261                                            Some("date-time") => "datetime_parsing".to_string(),
262                                            Some("time") => "time_parsing".to_string(),
263                                            Some("duration") => "duration_parsing".to_string(),
264                                            _ => "type_error".to_string(),
265                                        },
266                                        _ => "type_error".to_string(),
267                                    },
268                                    loc: vec!["query".to_string(), param_def.error_key.clone()],
269                                    msg: match item_type {
270                                        Some("integer") => {
271                                            "Input should be a valid integer, unable to parse string as an integer"
272                                                .to_string()
273                                        }
274                                        Some("number") => {
275                                            "Input should be a valid number, unable to parse string as a number"
276                                                .to_string()
277                                        }
278                                        Some("boolean") => {
279                                            "Input should be a valid boolean, unable to interpret input".to_string()
280                                        }
281                                        Some("string") => match item_format {
282                                            Some("uuid") => format!("Input should be a valid UUID, {}", e),
283                                            Some("date") => format!("Input should be a valid date, {}", e),
284                                            Some("date-time") => format!("Input should be a valid datetime, {}", e),
285                                            Some("time") => format!("Input should be a valid time, {}", e),
286                                            Some("duration") => format!("Input should be a valid duration, {}", e),
287                                            _ => e,
288                                        },
289                                        _ => e,
290                                    },
291                                    input: Value::String(value.clone()),
292                                    ctx: None,
293                                });
294                            }
295                        }
296                    }
297                    params_map.insert(param_def.name.clone(), Value::Array(out));
298                } else if let Some(value) = query_value {
299                    let array_value = if value.is_array() {
300                        value.clone()
301                    } else {
302                        Value::Array(vec![value.clone()])
303                    };
304                    let (item_type, item_format) = self.array_item_type_and_format(&param_def.name);
305
306                    let coerced_items = match array_value.as_array() {
307                        Some(items) => {
308                            let mut out = Vec::with_capacity(items.len());
309                            for item in items {
310                                if let Some(text) = item.as_str() {
311                                    match Self::coerce_value(text, item_type, item_format) {
312                                        Ok(coerced) => out.push(coerced),
313                                        Err(e) => {
314                                            errors.push(ValidationErrorDetail {
315                                                error_type: match item_type {
316                                                    Some("integer") => "int_parsing".to_string(),
317                                                    Some("number") => "float_parsing".to_string(),
318                                                    Some("boolean") => "bool_parsing".to_string(),
319                                                    Some("string") => match item_format {
320                                                        Some("uuid") => "uuid_parsing".to_string(),
321                                                        Some("date") => "date_parsing".to_string(),
322                                                        Some("date-time") => "datetime_parsing".to_string(),
323                                                        Some("time") => "time_parsing".to_string(),
324                                                        Some("duration") => "duration_parsing".to_string(),
325                                                        _ => "type_error".to_string(),
326                                                    },
327                                                    _ => "type_error".to_string(),
328                                                },
329                                                loc: vec!["query".to_string(), param_def.error_key.clone()],
330                                                msg: match item_type {
331                                                    Some("integer") => "Input should be a valid integer, unable to parse string as an integer".to_string(),
332                                                    Some("number") => "Input should be a valid number, unable to parse string as a number".to_string(),
333                                                    Some("boolean") => "Input should be a valid boolean, unable to interpret input".to_string(),
334                                                    Some("string") => match item_format {
335                                                        Some("uuid") => format!("Input should be a valid UUID, {}", e),
336                                                        Some("date") => format!("Input should be a valid date, {}", e),
337                                                        Some("date-time") => format!("Input should be a valid datetime, {}", e),
338                                                        Some("time") => format!("Input should be a valid time, {}", e),
339                                                        Some("duration") => format!("Input should be a valid duration, {}", e),
340                                                        _ => e.clone(),
341                                                    },
342                                                    _ => e.clone(),
343                                                },
344                                                input: Value::String(text.to_string()),
345                                                ctx: None,
346                                            });
347                                        }
348                                    }
349                                } else {
350                                    out.push(item.clone());
351                                }
352                            }
353                            out
354                        }
355                        None => Vec::new(),
356                    };
357
358                    params_map.insert(param_def.name.clone(), Value::Array(coerced_items));
359                }
360                continue;
361            }
362
363            let raw_value_string = self.raw_value_for_error(param_def, raw_query_params, path_params, headers, cookies);
364
365            if param_def.required && raw_value_string.is_none() {
366                let source_str = match param_def.source {
367                    ParameterSource::Query => "query",
368                    ParameterSource::Path => "path",
369                    ParameterSource::Header => "headers",
370                    ParameterSource::Cookie => "cookie",
371                };
372                errors.push(ValidationErrorDetail {
373                    error_type: "missing".to_string(),
374                    loc: vec![source_str.to_string(), param_def.error_key.clone()],
375                    msg: "Field required".to_string(),
376                    input: Value::Null,
377                    ctx: None,
378                });
379                continue;
380            }
381
382            if let Some(value_str) = raw_value_string {
383                match Self::coerce_value(
384                    value_str,
385                    param_def.expected_type.as_deref(),
386                    param_def.format.as_deref(),
387                ) {
388                    Ok(coerced) => {
389                        params_map.insert(param_def.name.clone(), coerced);
390                    }
391                    Err(e) => {
392                        let source_str = match param_def.source {
393                            ParameterSource::Query => "query",
394                            ParameterSource::Path => "path",
395                            ParameterSource::Header => "headers",
396                            ParameterSource::Cookie => "cookie",
397                        };
398                        let (error_type, error_msg) =
399                            match (param_def.expected_type.as_deref(), param_def.format.as_deref()) {
400                                (Some("integer"), _) => (
401                                    "int_parsing",
402                                    "Input should be a valid integer, unable to parse string as an integer".to_string(),
403                                ),
404                                (Some("number"), _) => (
405                                    "float_parsing",
406                                    "Input should be a valid number, unable to parse string as a number".to_string(),
407                                ),
408                                (Some("boolean"), _) => (
409                                    "bool_parsing",
410                                    "Input should be a valid boolean, unable to interpret input".to_string(),
411                                ),
412                                (Some("string"), Some("uuid")) => {
413                                    ("uuid_parsing", format!("Input should be a valid UUID, {}", e))
414                                }
415                                (Some("string"), Some("date")) => {
416                                    ("date_parsing", format!("Input should be a valid date, {}", e))
417                                }
418                                (Some("string"), Some("date-time")) => {
419                                    ("datetime_parsing", format!("Input should be a valid datetime, {}", e))
420                                }
421                                (Some("string"), Some("time")) => {
422                                    ("time_parsing", format!("Input should be a valid time, {}", e))
423                                }
424                                (Some("string"), Some("duration")) => {
425                                    ("duration_parsing", format!("Input should be a valid duration, {}", e))
426                                }
427                                _ => ("type_error", e),
428                            };
429                        errors.push(ValidationErrorDetail {
430                            error_type: error_type.to_string(),
431                            loc: vec![source_str.to_string(), param_def.error_key.clone()],
432                            msg: error_msg,
433                            input: Value::String(value_str.to_string()),
434                            ctx: None,
435                        });
436                    }
437                }
438            }
439        }
440
441        if !errors.is_empty() {
442            return Err(ValidationError { errors });
443        }
444
445        let params_json = Value::Object(params_map);
446        if let Some(schema_validator) = &self.inner.schema_validator {
447            match schema_validator.validate(&params_json) {
448                Ok(_) => Ok(params_json),
449                Err(mut validation_err) => {
450                    for error in &mut validation_err.errors {
451                        if error.loc.len() >= 2 && error.loc[0] == "body" {
452                            let param_name = &error.loc[1];
453                            if let Some(param_def) = self.inner.parameter_defs.iter().find(|p| &p.name == param_name) {
454                                let source_str = match param_def.source {
455                                    ParameterSource::Query => "query",
456                                    ParameterSource::Path => "path",
457                                    ParameterSource::Header => "headers",
458                                    ParameterSource::Cookie => "cookie",
459                                };
460                                error.loc[0] = source_str.to_string();
461                                if param_def.source == ParameterSource::Header {
462                                    error.loc[1] = param_def.error_key.clone();
463                                }
464                                if let Some(raw_value) =
465                                    self.raw_value_for_error(param_def, raw_query_params, path_params, headers, cookies)
466                                {
467                                    error.input = Value::String(raw_value.to_string());
468                                }
469                            }
470                        }
471                    }
472                    Err(validation_err)
473                }
474            }
475        } else {
476            Ok(params_json)
477        }
478    }
479
480    fn raw_value_for_error<'a>(
481        &self,
482        param_def: &ParameterDef,
483        raw_query_params: &'a HashMap<String, Vec<String>>,
484        path_params: &'a HashMap<String, String>,
485        headers: &'a HashMap<String, String>,
486        cookies: &'a HashMap<String, String>,
487    ) -> Option<&'a str> {
488        match param_def.source {
489            ParameterSource::Query => raw_query_params
490                .get(&param_def.lookup_key)
491                .and_then(|values| values.first())
492                .map(String::as_str),
493            ParameterSource::Path => path_params.get(&param_def.lookup_key).map(String::as_str),
494            ParameterSource::Header => headers.get(&param_def.lookup_key).map(String::as_str),
495            ParameterSource::Cookie => cookies.get(&param_def.lookup_key).map(String::as_str),
496        }
497    }
498
499    fn array_item_type_and_format(&self, name: &str) -> (Option<&str>, Option<&str>) {
500        let Some(prop) = self
501            .inner
502            .schema
503            .get("properties")
504            .and_then(|value| value.as_object())
505            .and_then(|props| props.get(name))
506        else {
507            return (None, None);
508        };
509
510        let Some(items) = prop.get("items") else {
511            return (None, None);
512        };
513
514        let item_type = items.get("type").and_then(|value| value.as_str());
515        let item_format = items.get("format").and_then(|value| value.as_str());
516        (item_type, item_format)
517    }
518
519    /// Coerce a string value to the expected JSON type
520    fn coerce_value(value: &str, expected_type: Option<&str>, format: Option<&str>) -> Result<Value, String> {
521        if let Some(fmt) = format {
522            match fmt {
523                "uuid" => {
524                    Self::validate_uuid_format(value)?;
525                    return Ok(json!(value));
526                }
527                "date" => {
528                    Self::validate_date_format(value)?;
529                    return Ok(json!(value));
530                }
531                "date-time" => {
532                    Self::validate_datetime_format(value)?;
533                    return Ok(json!(value));
534                }
535                "time" => {
536                    Self::validate_time_format(value)?;
537                    return Ok(json!(value));
538                }
539                "duration" => {
540                    Self::validate_duration_format(value)?;
541                    return Ok(json!(value));
542                }
543                _ => {}
544            }
545        }
546
547        match expected_type {
548            Some("integer") => value
549                .parse::<i64>()
550                .map(|i| json!(i))
551                .map_err(|e| format!("Invalid integer: {}", e)),
552            Some("number") => value
553                .parse::<f64>()
554                .map(|f| json!(f))
555                .map_err(|e| format!("Invalid number: {}", e)),
556            Some("boolean") => {
557                if value.is_empty() {
558                    return Ok(json!(false));
559                }
560                let value_lower = value.to_lowercase();
561                if value_lower == "true" || value == "1" {
562                    Ok(json!(true))
563                } else if value_lower == "false" || value == "0" {
564                    Ok(json!(false))
565                } else {
566                    Err(format!("Invalid boolean: {}", value))
567                }
568            }
569            _ => Ok(json!(value)),
570        }
571    }
572
573    /// Validate ISO 8601 date format: YYYY-MM-DD
574    fn validate_date_format(value: &str) -> Result<(), String> {
575        jiff::civil::Date::strptime("%Y-%m-%d", value)
576            .map(|_| ())
577            .map_err(|e| format!("Invalid date format: {}", e))
578    }
579
580    /// Validate ISO 8601 datetime format
581    fn validate_datetime_format(value: &str) -> Result<(), String> {
582        use std::str::FromStr;
583        jiff::Timestamp::from_str(value)
584            .map(|_| ())
585            .map_err(|e| format!("Invalid datetime format: {}", e))
586    }
587
588    /// Validate ISO 8601 time format: HH:MM:SS or HH:MM:SS.ffffff
589    fn validate_time_format(value: &str) -> Result<(), String> {
590        let (time_part, offset_part) = if let Some(stripped) = value.strip_suffix('Z') {
591            (stripped, "Z")
592        } else {
593            let plus = value.rfind('+');
594            let minus = value.rfind('-');
595            let split_at = match (plus, minus) {
596                (Some(p), Some(m)) => Some(std::cmp::max(p, m)),
597                (Some(p), None) => Some(p),
598                (None, Some(m)) => Some(m),
599                (None, None) => None,
600            }
601            .ok_or_else(|| "Invalid time format: missing timezone offset".to_string())?;
602
603            if split_at < 8 {
604                return Err("Invalid time format: timezone offset position is invalid".to_string());
605            }
606
607            (&value[..split_at], &value[split_at..])
608        };
609
610        let base_time = time_part.split('.').next().unwrap_or(time_part);
611        jiff::civil::Time::strptime("%H:%M:%S", base_time).map_err(|e| format!("Invalid time format: {}", e))?;
612
613        if let Some((_, frac)) = time_part.split_once('.')
614            && (frac.is_empty() || frac.len() > 9 || !frac.chars().all(|c| c.is_ascii_digit()))
615        {
616            return Err("Invalid time format: fractional seconds must be 1-9 digits".to_string());
617        }
618
619        if offset_part != "Z" {
620            let sign = offset_part
621                .chars()
622                .next()
623                .ok_or_else(|| "Invalid time format: empty timezone offset".to_string())?;
624            if sign != '+' && sign != '-' {
625                return Err("Invalid time format: timezone offset must start with + or -".to_string());
626            }
627
628            let rest = &offset_part[1..];
629            let (hours_str, minutes_str) = rest
630                .split_once(':')
631                .ok_or_else(|| "Invalid time format: timezone offset must be ±HH:MM".to_string())?;
632            let hours: u8 = hours_str
633                .parse()
634                .map_err(|_| "Invalid time format: invalid timezone hours".to_string())?;
635            let minutes: u8 = minutes_str
636                .parse()
637                .map_err(|_| "Invalid time format: invalid timezone minutes".to_string())?;
638            if hours > 23 || minutes > 59 {
639                return Err("Invalid time format: timezone offset out of range".to_string());
640            }
641        }
642
643        Ok(())
644    }
645
646    /// Validate duration format (simplified - accept ISO 8601 duration or simple formats)
647    fn validate_duration_format(value: &str) -> Result<(), String> {
648        use std::str::FromStr;
649        jiff::Span::from_str(value)
650            .map(|_| ())
651            .map_err(|e| format!("Invalid duration format: {}", e))
652    }
653
654    /// Validate UUID format
655    fn validate_uuid_format(value: &str) -> Result<(), String> {
656        use std::str::FromStr;
657        uuid::Uuid::from_str(value)
658            .map(|_| ())
659            .map_err(|_e| format!("invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `{}` at {}",
660                value.chars().next().unwrap_or('?'),
661                value.chars().position(|c| !c.is_ascii_hexdigit() && c != '-').unwrap_or(0)))
662    }
663
664    /// Create a validation schema without the "source" fields
665    /// (JSON Schema doesn't recognize "source" as a standard field)
666    fn create_validation_schema(schema: &Value) -> Value {
667        let mut schema = schema.clone();
668        let mut optional_fields: Vec<String> = Vec::new();
669
670        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
671            for (name, prop) in properties.iter_mut() {
672                if let Some(obj) = prop.as_object_mut() {
673                    obj.remove("source");
674                    if obj.get("optional").and_then(|v| v.as_bool()) == Some(true) {
675                        optional_fields.push(name.clone());
676                    }
677                    obj.remove("optional");
678                }
679            }
680        }
681
682        if !optional_fields.is_empty()
683            && let Some(required) = schema.get_mut("required").and_then(|r| r.as_array_mut())
684        {
685            required.retain(|value| {
686                value
687                    .as_str()
688                    .is_some_and(|field| !optional_fields.iter().any(|opt| opt == field))
689            });
690        }
691
692        schema
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use serde_json::json;
700
701    #[test]
702    fn test_parameter_schema_missing_source_returns_error() {
703        let schema = json!({
704            "type": "object",
705            "properties": {
706                "foo": {
707                    "type": "string"
708                }
709            }
710        });
711
712        let err = ParameterValidator::new(schema).expect_err("schema missing source should error");
713        assert!(
714            err.contains("missing required 'source' field"),
715            "unexpected error: {err}"
716        );
717    }
718
719    #[test]
720    fn test_parameter_schema_invalid_source_returns_error() {
721        let schema = json!({
722            "type": "object",
723            "properties": {
724                "foo": {
725                    "type": "string",
726                    "source": "invalid"
727                }
728            }
729        });
730
731        let err = ParameterValidator::new(schema).expect_err("invalid source should error");
732        assert!(err.contains("Invalid source"), "unexpected error: {err}");
733    }
734
735    #[test]
736    fn test_array_query_parameter() {
737        let schema = json!({
738            "type": "object",
739            "properties": {
740                "device_ids": {
741                    "type": "array",
742                    "items": {"type": "integer"},
743                    "source": "query"
744                }
745            },
746            "required": []
747        });
748
749        let validator = ParameterValidator::new(schema).unwrap();
750
751        let query_params = json!({
752            "device_ids": [1, 2]
753        });
754        let raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
755        let path_params = HashMap::new();
756
757        let result = validator.validate_and_extract(
758            &query_params,
759            &raw_query_params,
760            &path_params,
761            &HashMap::new(),
762            &HashMap::new(),
763        );
764        assert!(
765            result.is_ok(),
766            "Array query param validation failed: {:?}",
767            result.err()
768        );
769
770        let extracted = result.unwrap();
771        assert_eq!(extracted["device_ids"], json!([1, 2]));
772    }
773
774    #[test]
775    fn test_path_parameter_extraction() {
776        let schema = json!({
777            "type": "object",
778            "properties": {
779                "item_id": {
780                    "type": "string",
781                    "source": "path"
782                }
783            },
784            "required": ["item_id"]
785        });
786
787        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
788
789        let mut path_params = HashMap::new();
790        path_params.insert("item_id".to_string(), "foobar".to_string());
791        let query_params = json!({});
792        let raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
793
794        let result = validator.validate_and_extract(
795            &query_params,
796            &raw_query_params,
797            &path_params,
798            &HashMap::new(),
799            &HashMap::new(),
800        );
801        assert!(result.is_ok(), "Validation should succeed: {:?}", result);
802
803        let params = result.unwrap();
804        assert_eq!(params, json!({"item_id": "foobar"}));
805    }
806
807    #[test]
808    fn test_boolean_path_parameter_coercion() {
809        let schema = json!({
810            "type": "object",
811            "properties": {
812                "value": {
813                    "type": "boolean",
814                    "source": "path"
815                }
816            },
817            "required": ["value"]
818        });
819
820        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
821
822        let mut path_params = HashMap::new();
823        path_params.insert("value".to_string(), "True".to_string());
824        let query_params = json!({});
825        let raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
826
827        let result = validator.validate_and_extract(
828            &query_params,
829            &raw_query_params,
830            &path_params,
831            &HashMap::new(),
832            &HashMap::new(),
833        );
834        if result.is_err() {
835            eprintln!("Error for 'True': {:?}", result);
836        }
837        assert!(result.is_ok(), "Validation should succeed for 'True': {:?}", result);
838        let params = result.unwrap();
839        assert_eq!(params, json!({"value": true}));
840
841        path_params.insert("value".to_string(), "1".to_string());
842        let query_params_1 = json!({});
843        let result = validator.validate_and_extract(
844            &query_params_1,
845            &raw_query_params,
846            &path_params,
847            &HashMap::new(),
848            &HashMap::new(),
849        );
850        assert!(result.is_ok(), "Validation should succeed for '1': {:?}", result);
851        let params = result.unwrap();
852        assert_eq!(params, json!({"value": true}));
853
854        path_params.insert("value".to_string(), "false".to_string());
855        let query_params_false = json!({});
856        let result = validator.validate_and_extract(
857            &query_params_false,
858            &raw_query_params,
859            &path_params,
860            &HashMap::new(),
861            &HashMap::new(),
862        );
863        assert!(result.is_ok(), "Validation should succeed for 'false': {:?}", result);
864        let params = result.unwrap();
865        assert_eq!(params, json!({"value": false}));
866
867        path_params.insert("value".to_string(), "TRUE".to_string());
868        let query_params_true = json!({});
869        let result = validator.validate_and_extract(
870            &query_params_true,
871            &raw_query_params,
872            &path_params,
873            &HashMap::new(),
874            &HashMap::new(),
875        );
876        assert!(result.is_ok(), "Validation should succeed for 'TRUE': {:?}", result);
877        let params = result.unwrap();
878        assert_eq!(params, json!({"value": true}));
879    }
880
881    #[test]
882    fn test_boolean_query_parameter_coercion() {
883        let schema = json!({
884            "type": "object",
885            "properties": {
886                "flag": {
887                    "type": "boolean",
888                    "source": "query"
889                }
890            },
891            "required": ["flag"]
892        });
893
894        let validator = ParameterValidator::new(schema).expect("Failed to create validator");
895        let path_params = HashMap::new();
896
897        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
898        raw_query_params.insert("flag".to_string(), vec!["1".to_string()]);
899        let query_params = json!({"flag": 1});
900        let result = validator.validate_and_extract(
901            &query_params,
902            &raw_query_params,
903            &path_params,
904            &HashMap::new(),
905            &HashMap::new(),
906        );
907        assert!(result.is_ok(), "Validation should succeed for integer 1: {:?}", result);
908        let params = result.unwrap();
909        assert_eq!(params, json!({"flag": true}));
910
911        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
912        raw_query_params.insert("flag".to_string(), vec!["0".to_string()]);
913        let query_params = json!({"flag": 0});
914        let result = validator.validate_and_extract(
915            &query_params,
916            &raw_query_params,
917            &path_params,
918            &HashMap::new(),
919            &HashMap::new(),
920        );
921        assert!(result.is_ok(), "Validation should succeed for integer 0: {:?}", result);
922        let params = result.unwrap();
923        assert_eq!(params, json!({"flag": false}));
924
925        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
926        raw_query_params.insert("flag".to_string(), vec!["true".to_string()]);
927        let query_params = json!({"flag": true});
928        let result = validator.validate_and_extract(
929            &query_params,
930            &raw_query_params,
931            &path_params,
932            &HashMap::new(),
933            &HashMap::new(),
934        );
935        assert!(
936            result.is_ok(),
937            "Validation should succeed for boolean true: {:?}",
938            result
939        );
940        let params = result.unwrap();
941        assert_eq!(params, json!({"flag": true}));
942
943        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
944        raw_query_params.insert("flag".to_string(), vec!["false".to_string()]);
945        let query_params = json!({"flag": false});
946        let result = validator.validate_and_extract(
947            &query_params,
948            &raw_query_params,
949            &path_params,
950            &HashMap::new(),
951            &HashMap::new(),
952        );
953        assert!(
954            result.is_ok(),
955            "Validation should succeed for boolean false: {:?}",
956            result
957        );
958        let params = result.unwrap();
959        assert_eq!(params, json!({"flag": false}));
960    }
961
962    #[test]
963    fn test_integer_coercion_invalid_format_returns_error() {
964        let schema = json!({
965            "type": "object",
966            "properties": {
967                "count": {
968                    "type": "integer",
969                    "source": "query"
970                }
971            },
972            "required": ["count"]
973        });
974
975        let validator = ParameterValidator::new(schema).unwrap();
976        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
977        raw_query_params.insert("count".to_string(), vec!["not_a_number".to_string()]);
978
979        let result = validator.validate_and_extract(
980            &json!({"count": "not_a_number"}),
981            &raw_query_params,
982            &HashMap::new(),
983            &HashMap::new(),
984            &HashMap::new(),
985        );
986
987        assert!(result.is_err(), "Should fail to coerce non-integer string");
988        let err = result.unwrap_err();
989        assert_eq!(err.errors.len(), 1);
990        assert_eq!(err.errors[0].error_type, "int_parsing");
991        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "count".to_string()]);
992        assert!(err.errors[0].msg.contains("valid integer"));
993    }
994
995    #[test]
996    fn test_integer_coercion_with_letters_mixed_returns_error() {
997        let schema = json!({
998            "type": "object",
999            "properties": {
1000                "id": {
1001                    "type": "integer",
1002                    "source": "path"
1003                }
1004            },
1005            "required": ["id"]
1006        });
1007
1008        let validator = ParameterValidator::new(schema).unwrap();
1009        let mut path_params = HashMap::new();
1010        path_params.insert("id".to_string(), "123abc".to_string());
1011
1012        let result = validator.validate_and_extract(
1013            &json!({}),
1014            &HashMap::new(),
1015            &path_params,
1016            &HashMap::new(),
1017            &HashMap::new(),
1018        );
1019
1020        assert!(result.is_err());
1021        let err = result.unwrap_err();
1022        assert_eq!(err.errors[0].error_type, "int_parsing");
1023    }
1024
1025    #[test]
1026    fn test_integer_coercion_overflow_returns_error() {
1027        let schema = json!({
1028            "type": "object",
1029            "properties": {
1030                "big_num": {
1031                    "type": "integer",
1032                    "source": "query"
1033                }
1034            },
1035            "required": ["big_num"]
1036        });
1037
1038        let validator = ParameterValidator::new(schema).unwrap();
1039        let too_large = "9223372036854775808";
1040        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1041        raw_query_params.insert("big_num".to_string(), vec![too_large.to_string()]);
1042
1043        let result = validator.validate_and_extract(
1044            &json!({"big_num": too_large}),
1045            &raw_query_params,
1046            &HashMap::new(),
1047            &HashMap::new(),
1048            &HashMap::new(),
1049        );
1050
1051        assert!(result.is_err(), "Should fail on integer overflow");
1052        let err = result.unwrap_err();
1053        assert_eq!(err.errors[0].error_type, "int_parsing");
1054    }
1055
1056    #[test]
1057    fn test_integer_coercion_negative_overflow_returns_error() {
1058        let schema = json!({
1059            "type": "object",
1060            "properties": {
1061                "small_num": {
1062                    "type": "integer",
1063                    "source": "query"
1064                }
1065            },
1066            "required": ["small_num"]
1067        });
1068
1069        let validator = ParameterValidator::new(schema).unwrap();
1070        let too_small = "-9223372036854775809";
1071        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1072        raw_query_params.insert("small_num".to_string(), vec![too_small.to_string()]);
1073
1074        let result = validator.validate_and_extract(
1075            &json!({"small_num": too_small}),
1076            &raw_query_params,
1077            &HashMap::new(),
1078            &HashMap::new(),
1079            &HashMap::new(),
1080        );
1081
1082        assert!(result.is_err());
1083        let err = result.unwrap_err();
1084        assert_eq!(err.errors[0].error_type, "int_parsing");
1085    }
1086
1087    #[test]
1088    fn test_optional_field_overrides_required_list() {
1089        let schema = json!({
1090            "type": "object",
1091            "properties": {
1092                "maybe": {
1093                    "type": "string",
1094                    "source": "query",
1095                    "optional": true
1096                }
1097            },
1098            "required": ["maybe"]
1099        });
1100
1101        let validator = ParameterValidator::new(schema).unwrap();
1102
1103        let result = validator.validate_and_extract(
1104            &json!({}),
1105            &HashMap::new(),
1106            &HashMap::new(),
1107            &HashMap::new(),
1108            &HashMap::new(),
1109        );
1110
1111        assert!(result.is_ok(), "optional required field should not error: {result:?}");
1112        assert_eq!(result.unwrap(), json!({}));
1113    }
1114
1115    #[test]
1116    fn test_header_name_is_normalized_for_lookup_and_errors() {
1117        let schema = json!({
1118            "type": "object",
1119            "properties": {
1120                "x_request_id": {
1121                    "type": "string",
1122                    "source": "header"
1123                }
1124            },
1125            "required": ["x_request_id"]
1126        });
1127
1128        let validator = ParameterValidator::new(schema).unwrap();
1129
1130        let mut headers = HashMap::new();
1131        headers.insert("x-request-id".to_string(), "abc123".to_string());
1132
1133        let ok = validator
1134            .validate_and_extract(&json!({}), &HashMap::new(), &HashMap::new(), &headers, &HashMap::new())
1135            .unwrap();
1136        assert_eq!(ok, json!({"x_request_id": "abc123"}));
1137
1138        let err = validator
1139            .validate_and_extract(
1140                &json!({}),
1141                &HashMap::new(),
1142                &HashMap::new(),
1143                &HashMap::new(),
1144                &HashMap::new(),
1145            )
1146            .unwrap_err();
1147        assert_eq!(
1148            err.errors[0].loc,
1149            vec!["headers".to_string(), "x-request-id".to_string()]
1150        );
1151    }
1152
1153    #[test]
1154    fn test_boolean_empty_string_coerces_to_false() {
1155        let schema = json!({
1156            "type": "object",
1157            "properties": {
1158                "flag": {
1159                    "type": "boolean",
1160                    "source": "query"
1161                }
1162            },
1163            "required": ["flag"]
1164        });
1165
1166        let validator = ParameterValidator::new(schema).unwrap();
1167        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1168        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
1169
1170        let result = validator
1171            .validate_and_extract(
1172                &json!({"flag": ""}),
1173                &raw_query_params,
1174                &HashMap::new(),
1175                &HashMap::new(),
1176                &HashMap::new(),
1177            )
1178            .unwrap();
1179        assert_eq!(result, json!({"flag": false}));
1180    }
1181
1182    #[test]
1183    fn test_uuid_format_validation_returns_uuid_parsing_error() {
1184        let schema = json!({
1185            "type": "object",
1186            "properties": {
1187                "id": {
1188                    "type": "string",
1189                    "format": "uuid",
1190                    "source": "query"
1191                }
1192            },
1193            "required": ["id"]
1194        });
1195
1196        let validator = ParameterValidator::new(schema).unwrap();
1197        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1198        raw_query_params.insert("id".to_string(), vec!["not-a-uuid".to_string()]);
1199
1200        let err = validator
1201            .validate_and_extract(
1202                &json!({"id": "not-a-uuid"}),
1203                &raw_query_params,
1204                &HashMap::new(),
1205                &HashMap::new(),
1206                &HashMap::new(),
1207            )
1208            .unwrap_err();
1209
1210        assert_eq!(err.errors[0].error_type, "uuid_parsing");
1211        assert!(
1212            err.errors[0].msg.contains("valid UUID"),
1213            "msg was {}",
1214            err.errors[0].msg
1215        );
1216    }
1217
1218    #[test]
1219    fn test_array_query_parameter_coercion_error_reports_item_parse_failure() {
1220        let schema = json!({
1221            "type": "object",
1222            "properties": {
1223                "ids": {
1224                    "type": "array",
1225                    "items": {"type": "integer"},
1226                    "source": "query"
1227                }
1228            },
1229            "required": ["ids"]
1230        });
1231
1232        let validator = ParameterValidator::new(schema).unwrap();
1233        let query_params = json!({ "ids": ["nope"] });
1234
1235        let err = validator
1236            .validate_and_extract(
1237                &query_params,
1238                &HashMap::new(),
1239                &HashMap::new(),
1240                &HashMap::new(),
1241                &HashMap::new(),
1242            )
1243            .unwrap_err();
1244
1245        assert_eq!(err.errors[0].error_type, "int_parsing");
1246        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "ids".to_string()]);
1247    }
1248
1249    #[test]
1250    fn test_float_coercion_invalid_format_returns_error() {
1251        let schema = json!({
1252            "type": "object",
1253            "properties": {
1254                "price": {
1255                    "type": "number",
1256                    "source": "query"
1257                }
1258            },
1259            "required": ["price"]
1260        });
1261
1262        let validator = ParameterValidator::new(schema).unwrap();
1263        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1264        raw_query_params.insert("price".to_string(), vec!["not.a.number".to_string()]);
1265
1266        let result = validator.validate_and_extract(
1267            &json!({"price": "not.a.number"}),
1268            &raw_query_params,
1269            &HashMap::new(),
1270            &HashMap::new(),
1271            &HashMap::new(),
1272        );
1273
1274        assert!(result.is_err());
1275        let err = result.unwrap_err();
1276        assert_eq!(err.errors[0].error_type, "float_parsing");
1277        assert!(err.errors[0].msg.contains("valid number"));
1278    }
1279
1280    #[test]
1281    fn test_float_coercion_scientific_notation_success() {
1282        let schema = json!({
1283            "type": "object",
1284            "properties": {
1285                "value": {
1286                    "type": "number",
1287                    "source": "query"
1288                }
1289            },
1290            "required": ["value"]
1291        });
1292
1293        let validator = ParameterValidator::new(schema).unwrap();
1294        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1295        raw_query_params.insert("value".to_string(), vec!["1.5e10".to_string()]);
1296
1297        let result = validator.validate_and_extract(
1298            &json!({"value": 1.5e10}),
1299            &raw_query_params,
1300            &HashMap::new(),
1301            &HashMap::new(),
1302            &HashMap::new(),
1303        );
1304
1305        assert!(result.is_ok());
1306        let extracted = result.unwrap();
1307        assert_eq!(extracted["value"], json!(1.5e10));
1308    }
1309
1310    #[test]
1311    fn test_boolean_coercion_empty_string_returns_false() {
1312        // BUG: Empty string returns false instead of error - this is behavior to verify
1313        let schema = json!({
1314            "type": "object",
1315            "properties": {
1316                "flag": {
1317                    "type": "boolean",
1318                    "source": "query"
1319                }
1320            },
1321            "required": ["flag"]
1322        });
1323
1324        let validator = ParameterValidator::new(schema).unwrap();
1325        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1326        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
1327
1328        let result = validator.validate_and_extract(
1329            &json!({"flag": ""}),
1330            &raw_query_params,
1331            &HashMap::new(),
1332            &HashMap::new(),
1333            &HashMap::new(),
1334        );
1335
1336        assert!(result.is_ok());
1337        let extracted = result.unwrap();
1338        assert_eq!(extracted["flag"], json!(false));
1339    }
1340
1341    #[test]
1342    fn test_boolean_coercion_whitespace_string_returns_error() {
1343        let schema = json!({
1344            "type": "object",
1345            "properties": {
1346                "flag": {
1347                    "type": "boolean",
1348                    "source": "query"
1349                }
1350            },
1351            "required": ["flag"]
1352        });
1353
1354        let validator = ParameterValidator::new(schema).unwrap();
1355        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1356        raw_query_params.insert("flag".to_string(), vec!["   ".to_string()]);
1357
1358        let result = validator.validate_and_extract(
1359            &json!({"flag": "   "}),
1360            &raw_query_params,
1361            &HashMap::new(),
1362            &HashMap::new(),
1363            &HashMap::new(),
1364        );
1365
1366        assert!(result.is_err(), "Whitespace-only string should fail boolean parsing");
1367        let err = result.unwrap_err();
1368        assert_eq!(err.errors[0].error_type, "bool_parsing");
1369    }
1370
1371    #[test]
1372    fn test_boolean_coercion_invalid_value_returns_error() {
1373        let schema = json!({
1374            "type": "object",
1375            "properties": {
1376                "enabled": {
1377                    "type": "boolean",
1378                    "source": "path"
1379                }
1380            },
1381            "required": ["enabled"]
1382        });
1383
1384        let validator = ParameterValidator::new(schema).unwrap();
1385        let mut path_params = HashMap::new();
1386        path_params.insert("enabled".to_string(), "maybe".to_string());
1387
1388        let result = validator.validate_and_extract(
1389            &json!({}),
1390            &HashMap::new(),
1391            &path_params,
1392            &HashMap::new(),
1393            &HashMap::new(),
1394        );
1395
1396        assert!(result.is_err());
1397        let err = result.unwrap_err();
1398        assert_eq!(err.errors[0].error_type, "bool_parsing");
1399        assert!(err.errors[0].msg.contains("valid boolean"));
1400    }
1401
1402    #[test]
1403    fn test_required_query_parameter_missing_returns_error() {
1404        let schema = json!({
1405            "type": "object",
1406            "properties": {
1407                "required_param": {
1408                    "type": "string",
1409                    "source": "query"
1410                }
1411            },
1412            "required": ["required_param"]
1413        });
1414
1415        let validator = ParameterValidator::new(schema).unwrap();
1416
1417        let result = validator.validate_and_extract(
1418            &json!({}),
1419            &HashMap::new(),
1420            &HashMap::new(),
1421            &HashMap::new(),
1422            &HashMap::new(),
1423        );
1424
1425        assert!(result.is_err());
1426        let err = result.unwrap_err();
1427        assert_eq!(err.errors[0].error_type, "missing");
1428        assert_eq!(
1429            err.errors[0].loc,
1430            vec!["query".to_string(), "required_param".to_string()]
1431        );
1432        assert!(err.errors[0].msg.contains("required"));
1433    }
1434
1435    #[test]
1436    fn test_required_path_parameter_missing_returns_error() {
1437        let schema = json!({
1438            "type": "object",
1439            "properties": {
1440                "user_id": {
1441                    "type": "string",
1442                    "source": "path"
1443                }
1444            },
1445            "required": ["user_id"]
1446        });
1447
1448        let validator = ParameterValidator::new(schema).unwrap();
1449
1450        let result = validator.validate_and_extract(
1451            &json!({}),
1452            &HashMap::new(),
1453            &HashMap::new(),
1454            &HashMap::new(),
1455            &HashMap::new(),
1456        );
1457
1458        assert!(result.is_err());
1459        let err = result.unwrap_err();
1460        assert_eq!(err.errors[0].error_type, "missing");
1461        assert_eq!(err.errors[0].loc, vec!["path".to_string(), "user_id".to_string()]);
1462    }
1463
1464    #[test]
1465    fn test_required_header_parameter_missing_returns_error() {
1466        let schema = json!({
1467            "type": "object",
1468            "properties": {
1469                "Authorization": {
1470                    "type": "string",
1471                    "source": "header"
1472                }
1473            },
1474            "required": ["Authorization"]
1475        });
1476
1477        let validator = ParameterValidator::new(schema).unwrap();
1478
1479        let result = validator.validate_and_extract(
1480            &json!({}),
1481            &HashMap::new(),
1482            &HashMap::new(),
1483            &HashMap::new(),
1484            &HashMap::new(),
1485        );
1486
1487        assert!(result.is_err());
1488        let err = result.unwrap_err();
1489        assert_eq!(err.errors[0].error_type, "missing");
1490        assert_eq!(
1491            err.errors[0].loc,
1492            vec!["headers".to_string(), "authorization".to_string()]
1493        );
1494    }
1495
1496    #[test]
1497    fn test_required_cookie_parameter_missing_returns_error() {
1498        let schema = json!({
1499            "type": "object",
1500            "properties": {
1501                "session_id": {
1502                    "type": "string",
1503                    "source": "cookie"
1504                }
1505            },
1506            "required": ["session_id"]
1507        });
1508
1509        let validator = ParameterValidator::new(schema).unwrap();
1510
1511        let result = validator.validate_and_extract(
1512            &json!({}),
1513            &HashMap::new(),
1514            &HashMap::new(),
1515            &HashMap::new(),
1516            &HashMap::new(),
1517        );
1518
1519        assert!(result.is_err());
1520        let err = result.unwrap_err();
1521        assert_eq!(err.errors[0].error_type, "missing");
1522        assert_eq!(err.errors[0].loc, vec!["cookie".to_string(), "session_id".to_string()]);
1523    }
1524
1525    #[test]
1526    fn test_optional_parameter_missing_succeeds() {
1527        let schema = json!({
1528            "type": "object",
1529            "properties": {
1530                "optional_param": {
1531                    "type": "string",
1532                    "source": "query",
1533                    "optional": true
1534                }
1535            },
1536            "required": []
1537        });
1538
1539        let validator = ParameterValidator::new(schema).unwrap();
1540
1541        let result = validator.validate_and_extract(
1542            &json!({}),
1543            &HashMap::new(),
1544            &HashMap::new(),
1545            &HashMap::new(),
1546            &HashMap::new(),
1547        );
1548
1549        assert!(result.is_ok(), "Optional parameter should not cause error when missing");
1550        let extracted = result.unwrap();
1551        assert!(!extracted.as_object().unwrap().contains_key("optional_param"));
1552    }
1553
1554    #[test]
1555    fn test_uuid_validation_invalid_format_returns_error() {
1556        let schema = json!({
1557            "type": "object",
1558            "properties": {
1559                "id": {
1560                    "type": "string",
1561                    "format": "uuid",
1562                    "source": "path"
1563                }
1564            },
1565            "required": ["id"]
1566        });
1567
1568        let validator = ParameterValidator::new(schema).unwrap();
1569        let mut path_params = HashMap::new();
1570        path_params.insert("id".to_string(), "not-a-uuid".to_string());
1571
1572        let result = validator.validate_and_extract(
1573            &json!({}),
1574            &HashMap::new(),
1575            &path_params,
1576            &HashMap::new(),
1577            &HashMap::new(),
1578        );
1579
1580        assert!(result.is_err());
1581        let err = result.unwrap_err();
1582        assert_eq!(err.errors[0].error_type, "uuid_parsing");
1583        assert!(err.errors[0].msg.contains("UUID"));
1584    }
1585
1586    #[test]
1587    fn test_uuid_validation_uppercase_succeeds() {
1588        let schema = json!({
1589            "type": "object",
1590            "properties": {
1591                "id": {
1592                    "type": "string",
1593                    "format": "uuid",
1594                    "source": "query"
1595                }
1596            },
1597            "required": ["id"]
1598        });
1599
1600        let validator = ParameterValidator::new(schema).unwrap();
1601        let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
1602        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1603        raw_query_params.insert("id".to_string(), vec![valid_uuid.to_string()]);
1604
1605        let result = validator.validate_and_extract(
1606            &json!({"id": valid_uuid}),
1607            &raw_query_params,
1608            &HashMap::new(),
1609            &HashMap::new(),
1610            &HashMap::new(),
1611        );
1612
1613        assert!(result.is_ok());
1614        let extracted = result.unwrap();
1615        assert_eq!(extracted["id"], json!(valid_uuid));
1616    }
1617
1618    #[test]
1619    fn test_date_validation_invalid_format_returns_error() {
1620        let schema = json!({
1621            "type": "object",
1622            "properties": {
1623                "created_at": {
1624                    "type": "string",
1625                    "format": "date",
1626                    "source": "query"
1627                }
1628            },
1629            "required": ["created_at"]
1630        });
1631
1632        let validator = ParameterValidator::new(schema).unwrap();
1633        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1634        raw_query_params.insert("created_at".to_string(), vec!["2024/12/10".to_string()]);
1635
1636        let result = validator.validate_and_extract(
1637            &json!({"created_at": "2024/12/10"}),
1638            &raw_query_params,
1639            &HashMap::new(),
1640            &HashMap::new(),
1641            &HashMap::new(),
1642        );
1643
1644        assert!(result.is_err());
1645        let err = result.unwrap_err();
1646        assert_eq!(err.errors[0].error_type, "date_parsing");
1647        assert!(err.errors[0].msg.contains("date"));
1648    }
1649
1650    #[test]
1651    fn test_date_validation_valid_iso_succeeds() {
1652        let schema = json!({
1653            "type": "object",
1654            "properties": {
1655                "created_at": {
1656                    "type": "string",
1657                    "format": "date",
1658                    "source": "query"
1659                }
1660            },
1661            "required": ["created_at"]
1662        });
1663
1664        let validator = ParameterValidator::new(schema).unwrap();
1665        let valid_date = "2024-12-10";
1666        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1667        raw_query_params.insert("created_at".to_string(), vec![valid_date.to_string()]);
1668
1669        let result = validator.validate_and_extract(
1670            &json!({"created_at": valid_date}),
1671            &raw_query_params,
1672            &HashMap::new(),
1673            &HashMap::new(),
1674            &HashMap::new(),
1675        );
1676
1677        assert!(result.is_ok());
1678        let extracted = result.unwrap();
1679        assert_eq!(extracted["created_at"], json!(valid_date));
1680    }
1681
1682    #[test]
1683    fn test_datetime_validation_invalid_format_returns_error() {
1684        let schema = json!({
1685            "type": "object",
1686            "properties": {
1687                "timestamp": {
1688                    "type": "string",
1689                    "format": "date-time",
1690                    "source": "query"
1691                }
1692            },
1693            "required": ["timestamp"]
1694        });
1695
1696        let validator = ParameterValidator::new(schema).unwrap();
1697        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1698        raw_query_params.insert("timestamp".to_string(), vec!["not-a-datetime".to_string()]);
1699
1700        let result = validator.validate_and_extract(
1701            &json!({"timestamp": "not-a-datetime"}),
1702            &raw_query_params,
1703            &HashMap::new(),
1704            &HashMap::new(),
1705            &HashMap::new(),
1706        );
1707
1708        assert!(result.is_err());
1709        let err = result.unwrap_err();
1710        assert_eq!(err.errors[0].error_type, "datetime_parsing");
1711    }
1712
1713    #[test]
1714    fn test_time_validation_invalid_format_returns_error() {
1715        let schema = json!({
1716            "type": "object",
1717            "properties": {
1718                "start_time": {
1719                    "type": "string",
1720                    "format": "time",
1721                    "source": "query"
1722                }
1723            },
1724            "required": ["start_time"]
1725        });
1726
1727        let validator = ParameterValidator::new(schema).unwrap();
1728        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1729        raw_query_params.insert("start_time".to_string(), vec!["25:00:00".to_string()]);
1730
1731        let result = validator.validate_and_extract(
1732            &json!({"start_time": "25:00:00"}),
1733            &raw_query_params,
1734            &HashMap::new(),
1735            &HashMap::new(),
1736            &HashMap::new(),
1737        );
1738
1739        assert!(result.is_err());
1740        let err = result.unwrap_err();
1741        assert_eq!(err.errors[0].error_type, "time_parsing");
1742    }
1743
1744    #[test]
1745    fn test_time_validation_string_passthrough() {
1746        let schema = json!({
1747            "type": "object",
1748            "properties": {
1749                "start_time": {
1750                    "type": "string",
1751                    "source": "query"
1752                }
1753            },
1754            "required": ["start_time"]
1755        });
1756
1757        let validator = ParameterValidator::new(schema).unwrap();
1758        let time_string = "14:30:00";
1759        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1760        raw_query_params.insert("start_time".to_string(), vec![time_string.to_string()]);
1761
1762        let result = validator.validate_and_extract(
1763            &json!({"start_time": time_string}),
1764            &raw_query_params,
1765            &HashMap::new(),
1766            &HashMap::new(),
1767            &HashMap::new(),
1768        );
1769
1770        assert!(result.is_ok(), "String parameter should pass: {:?}", result);
1771        let extracted = result.unwrap();
1772        assert_eq!(extracted["start_time"], json!(time_string));
1773    }
1774
1775    #[test]
1776    fn test_duration_validation_invalid_format_returns_error() {
1777        let schema = json!({
1778            "type": "object",
1779            "properties": {
1780                "timeout": {
1781                    "type": "string",
1782                    "format": "duration",
1783                    "source": "query"
1784                }
1785            },
1786            "required": ["timeout"]
1787        });
1788
1789        let validator = ParameterValidator::new(schema).unwrap();
1790        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1791        raw_query_params.insert("timeout".to_string(), vec!["not-a-duration".to_string()]);
1792
1793        let result = validator.validate_and_extract(
1794            &json!({"timeout": "not-a-duration"}),
1795            &raw_query_params,
1796            &HashMap::new(),
1797            &HashMap::new(),
1798            &HashMap::new(),
1799        );
1800
1801        assert!(result.is_err());
1802        let err = result.unwrap_err();
1803        assert_eq!(err.errors[0].error_type, "duration_parsing");
1804    }
1805
1806    #[test]
1807    fn test_duration_validation_iso8601_succeeds() {
1808        let schema = json!({
1809            "type": "object",
1810            "properties": {
1811                "timeout": {
1812                    "type": "string",
1813                    "format": "duration",
1814                    "source": "query"
1815                }
1816            },
1817            "required": ["timeout"]
1818        });
1819
1820        let validator = ParameterValidator::new(schema).unwrap();
1821        let valid_duration = "PT5M";
1822        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1823        raw_query_params.insert("timeout".to_string(), vec![valid_duration.to_string()]);
1824
1825        let result = validator.validate_and_extract(
1826            &json!({"timeout": valid_duration}),
1827            &raw_query_params,
1828            &HashMap::new(),
1829            &HashMap::new(),
1830            &HashMap::new(),
1831        );
1832
1833        assert!(result.is_ok());
1834    }
1835
1836    #[test]
1837    fn test_header_name_normalization_with_underscores() {
1838        let schema = json!({
1839            "type": "object",
1840            "properties": {
1841                "X_Custom_Header": {
1842                    "type": "string",
1843                    "source": "header"
1844                }
1845            },
1846            "required": ["X_Custom_Header"]
1847        });
1848
1849        let validator = ParameterValidator::new(schema).unwrap();
1850        let mut headers = HashMap::new();
1851        headers.insert("x-custom-header".to_string(), "value".to_string());
1852
1853        let result =
1854            validator.validate_and_extract(&json!({}), &HashMap::new(), &HashMap::new(), &headers, &HashMap::new());
1855
1856        assert!(result.is_ok());
1857        let extracted = result.unwrap();
1858        assert_eq!(extracted["X_Custom_Header"], json!("value"));
1859    }
1860
1861    #[test]
1862    fn test_multiple_query_parameter_values_uses_first() {
1863        let schema = json!({
1864            "type": "object",
1865            "properties": {
1866                "id": {
1867                    "type": "integer",
1868                    "source": "query"
1869                }
1870            },
1871            "required": ["id"]
1872        });
1873
1874        let validator = ParameterValidator::new(schema).unwrap();
1875        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1876        raw_query_params.insert("id".to_string(), vec!["123".to_string(), "456".to_string()]);
1877
1878        let result = validator.validate_and_extract(
1879            &json!({"id": [123, 456]}),
1880            &raw_query_params,
1881            &HashMap::new(),
1882            &HashMap::new(),
1883            &HashMap::new(),
1884        );
1885
1886        assert!(result.is_ok(), "Should accept first value of multiple query params");
1887        let extracted = result.unwrap();
1888        assert_eq!(extracted["id"], json!(123));
1889    }
1890
1891    #[test]
1892    fn test_schema_creation_missing_source_field_returns_error() {
1893        let schema = json!({
1894            "type": "object",
1895            "properties": {
1896                "param": {
1897                    "type": "string"
1898                }
1899            },
1900            "required": []
1901        });
1902
1903        let result = ParameterValidator::new(schema);
1904        assert!(result.is_err(), "Schema without 'source' field should fail");
1905        let err_msg = result.unwrap_err();
1906        assert!(err_msg.contains("source"));
1907    }
1908
1909    #[test]
1910    fn test_schema_creation_invalid_source_value_returns_error() {
1911        let schema = json!({
1912            "type": "object",
1913            "properties": {
1914                "param": {
1915                    "type": "string",
1916                    "source": "invalid_source"
1917                }
1918            },
1919            "required": []
1920        });
1921
1922        let result = ParameterValidator::new(schema);
1923        assert!(result.is_err());
1924        let err_msg = result.unwrap_err();
1925        assert!(err_msg.contains("Invalid source"));
1926    }
1927
1928    #[test]
1929    fn test_multiple_errors_reported_together() {
1930        let schema = json!({
1931            "type": "object",
1932            "properties": {
1933                "count": {
1934                    "type": "integer",
1935                    "source": "query"
1936                },
1937                "user_id": {
1938                    "type": "string",
1939                    "source": "path"
1940                },
1941                "token": {
1942                    "type": "string",
1943                    "source": "header"
1944                }
1945            },
1946            "required": ["count", "user_id", "token"]
1947        });
1948
1949        let validator = ParameterValidator::new(schema).unwrap();
1950
1951        let result = validator.validate_and_extract(
1952            &json!({}),
1953            &HashMap::new(),
1954            &HashMap::new(),
1955            &HashMap::new(),
1956            &HashMap::new(),
1957        );
1958
1959        assert!(result.is_err());
1960        let err = result.unwrap_err();
1961        assert_eq!(err.errors.len(), 3);
1962        assert!(err.errors.iter().all(|e| e.error_type == "missing"));
1963    }
1964
1965    #[test]
1966    fn test_coercion_error_includes_original_value() {
1967        let schema = json!({
1968            "type": "object",
1969            "properties": {
1970                "age": {
1971                    "type": "integer",
1972                    "source": "query"
1973                }
1974            },
1975            "required": ["age"]
1976        });
1977
1978        let validator = ParameterValidator::new(schema).unwrap();
1979        let invalid_value = "not_an_int";
1980        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1981        raw_query_params.insert("age".to_string(), vec![invalid_value.to_string()]);
1982
1983        let result = validator.validate_and_extract(
1984            &json!({"age": invalid_value}),
1985            &raw_query_params,
1986            &HashMap::new(),
1987            &HashMap::new(),
1988            &HashMap::new(),
1989        );
1990
1991        assert!(result.is_err());
1992        let err = result.unwrap_err();
1993        assert_eq!(err.errors[0].input, json!(invalid_value));
1994    }
1995
1996    #[test]
1997    fn test_string_parameter_passes_through() {
1998        let schema = json!({
1999            "type": "object",
2000            "properties": {
2001                "name": {
2002                    "type": "string",
2003                    "source": "query"
2004                }
2005            },
2006            "required": ["name"]
2007        });
2008
2009        let validator = ParameterValidator::new(schema).unwrap();
2010        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2011        raw_query_params.insert("name".to_string(), vec!["Alice".to_string()]);
2012
2013        let result = validator.validate_and_extract(
2014            &json!({"name": "Alice"}),
2015            &raw_query_params,
2016            &HashMap::new(),
2017            &HashMap::new(),
2018            &HashMap::new(),
2019        );
2020
2021        assert!(result.is_ok());
2022        let extracted = result.unwrap();
2023        assert_eq!(extracted["name"], json!("Alice"));
2024    }
2025
2026    #[test]
2027    fn test_string_with_special_characters_passes_through() {
2028        let schema = json!({
2029            "type": "object",
2030            "properties": {
2031                "message": {
2032                    "type": "string",
2033                    "source": "query"
2034                }
2035            },
2036            "required": ["message"]
2037        });
2038
2039        let validator = ParameterValidator::new(schema).unwrap();
2040        let special_value = "Hello! @#$%^&*() Unicode: 你好";
2041        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2042        raw_query_params.insert("message".to_string(), vec![special_value.to_string()]);
2043
2044        let result = validator.validate_and_extract(
2045            &json!({"message": special_value}),
2046            &raw_query_params,
2047            &HashMap::new(),
2048            &HashMap::new(),
2049            &HashMap::new(),
2050        );
2051
2052        assert!(result.is_ok());
2053        let extracted = result.unwrap();
2054        assert_eq!(extracted["message"], json!(special_value));
2055    }
2056
2057    #[test]
2058    fn test_array_query_parameter_missing_required_returns_error() {
2059        let schema = json!({
2060            "type": "object",
2061            "properties": {
2062                "ids": {
2063                    "type": "array",
2064                    "items": {"type": "integer"},
2065                    "source": "query"
2066                }
2067            },
2068            "required": ["ids"]
2069        });
2070
2071        let validator = ParameterValidator::new(schema).unwrap();
2072
2073        let result = validator.validate_and_extract(
2074            &json!({}),
2075            &HashMap::new(),
2076            &HashMap::new(),
2077            &HashMap::new(),
2078            &HashMap::new(),
2079        );
2080
2081        assert!(result.is_err());
2082        let err = result.unwrap_err();
2083        assert_eq!(err.errors[0].error_type, "missing");
2084    }
2085
2086    #[test]
2087    fn test_empty_array_parameter_accepted() {
2088        let schema = json!({
2089            "type": "object",
2090            "properties": {
2091                "tags": {
2092                    "type": "array",
2093                    "items": {"type": "string"},
2094                    "source": "query"
2095                }
2096            },
2097            "required": ["tags"]
2098        });
2099
2100        let validator = ParameterValidator::new(schema).unwrap();
2101
2102        let result = validator.validate_and_extract(
2103            &json!({"tags": []}),
2104            &HashMap::new(),
2105            &HashMap::new(),
2106            &HashMap::new(),
2107            &HashMap::new(),
2108        );
2109
2110        assert!(result.is_ok());
2111        let extracted = result.unwrap();
2112        assert_eq!(extracted["tags"], json!([]));
2113    }
2114
2115    #[test]
2116    fn test_parameter_source_from_str_query() {
2117        assert_eq!(ParameterSource::from_str("query"), Some(ParameterSource::Query));
2118    }
2119
2120    #[test]
2121    fn test_parameter_source_from_str_path() {
2122        assert_eq!(ParameterSource::from_str("path"), Some(ParameterSource::Path));
2123    }
2124
2125    #[test]
2126    fn test_parameter_source_from_str_header() {
2127        assert_eq!(ParameterSource::from_str("header"), Some(ParameterSource::Header));
2128    }
2129
2130    #[test]
2131    fn test_parameter_source_from_str_cookie() {
2132        assert_eq!(ParameterSource::from_str("cookie"), Some(ParameterSource::Cookie));
2133    }
2134
2135    #[test]
2136    fn test_parameter_source_from_str_invalid() {
2137        assert_eq!(ParameterSource::from_str("invalid"), None);
2138    }
2139
2140    #[test]
2141    fn test_integer_with_plus_sign() {
2142        let schema = json!({
2143            "type": "object",
2144            "properties": {
2145                "count": {
2146                    "type": "integer",
2147                    "source": "query"
2148                }
2149            },
2150            "required": ["count"]
2151        });
2152
2153        let validator = ParameterValidator::new(schema).unwrap();
2154        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2155        raw_query_params.insert("count".to_string(), vec!["+123".to_string()]);
2156
2157        let result = validator.validate_and_extract(
2158            &json!({"count": "+123"}),
2159            &raw_query_params,
2160            &HashMap::new(),
2161            &HashMap::new(),
2162            &HashMap::new(),
2163        );
2164
2165        assert!(result.is_ok());
2166        let extracted = result.unwrap();
2167        assert_eq!(extracted["count"], json!(123));
2168    }
2169
2170    #[test]
2171    fn test_float_with_leading_dot() {
2172        let schema = json!({
2173            "type": "object",
2174            "properties": {
2175                "ratio": {
2176                    "type": "number",
2177                    "source": "query"
2178                }
2179            },
2180            "required": ["ratio"]
2181        });
2182
2183        let validator = ParameterValidator::new(schema).unwrap();
2184        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2185        raw_query_params.insert("ratio".to_string(), vec![".5".to_string()]);
2186
2187        let result = validator.validate_and_extract(
2188            &json!({"ratio": 0.5}),
2189            &raw_query_params,
2190            &HashMap::new(),
2191            &HashMap::new(),
2192            &HashMap::new(),
2193        );
2194
2195        assert!(result.is_ok());
2196        let extracted = result.unwrap();
2197        assert_eq!(extracted["ratio"], json!(0.5));
2198    }
2199
2200    #[test]
2201    fn test_float_with_trailing_dot() {
2202        let schema = json!({
2203            "type": "object",
2204            "properties": {
2205                "value": {
2206                    "type": "number",
2207                    "source": "query"
2208                }
2209            },
2210            "required": ["value"]
2211        });
2212
2213        let validator = ParameterValidator::new(schema).unwrap();
2214        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2215        raw_query_params.insert("value".to_string(), vec!["5.".to_string()]);
2216
2217        let result = validator.validate_and_extract(
2218            &json!({"value": 5.0}),
2219            &raw_query_params,
2220            &HashMap::new(),
2221            &HashMap::new(),
2222            &HashMap::new(),
2223        );
2224
2225        assert!(result.is_ok());
2226    }
2227
2228    #[test]
2229    fn test_boolean_case_insensitive_true() {
2230        let schema = json!({
2231            "type": "object",
2232            "properties": {
2233                "flag": {
2234                    "type": "boolean",
2235                    "source": "query"
2236                }
2237            },
2238            "required": ["flag"]
2239        });
2240
2241        let validator = ParameterValidator::new(schema).unwrap();
2242        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2243        raw_query_params.insert("flag".to_string(), vec!["TrUe".to_string()]);
2244
2245        let result = validator.validate_and_extract(
2246            &json!({"flag": true}),
2247            &raw_query_params,
2248            &HashMap::new(),
2249            &HashMap::new(),
2250            &HashMap::new(),
2251        );
2252
2253        assert!(result.is_ok());
2254        let extracted = result.unwrap();
2255        assert_eq!(extracted["flag"], json!(true));
2256    }
2257
2258    #[test]
2259    fn test_boolean_case_insensitive_false() {
2260        let schema = json!({
2261            "type": "object",
2262            "properties": {
2263                "flag": {
2264                    "type": "boolean",
2265                    "source": "query"
2266                }
2267            },
2268            "required": ["flag"]
2269        });
2270
2271        let validator = ParameterValidator::new(schema).unwrap();
2272        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2273        raw_query_params.insert("flag".to_string(), vec!["FaLsE".to_string()]);
2274
2275        let result = validator.validate_and_extract(
2276            &json!({"flag": false}),
2277            &raw_query_params,
2278            &HashMap::new(),
2279            &HashMap::new(),
2280            &HashMap::new(),
2281        );
2282
2283        assert!(result.is_ok());
2284        let extracted = result.unwrap();
2285        assert_eq!(extracted["flag"], json!(false));
2286    }
2287
2288    #[test]
2289    fn test_missing_required_header_uses_kebab_case_in_error_loc() {
2290        let schema = json!({
2291            "type": "object",
2292            "properties": {
2293                "x_api_key": {
2294                    "type": "string",
2295                    "source": "header"
2296                }
2297            },
2298            "required": ["x_api_key"]
2299        });
2300
2301        let validator = ParameterValidator::new(schema).unwrap();
2302
2303        let result = validator.validate_and_extract(
2304            &json!({}),
2305            &HashMap::new(),
2306            &HashMap::new(),
2307            &HashMap::new(),
2308            &HashMap::new(),
2309        );
2310
2311        assert!(result.is_err(), "expected missing header to fail");
2312        let err = result.unwrap_err();
2313        assert_eq!(err.errors.len(), 1);
2314        assert_eq!(err.errors[0].error_type, "missing");
2315        assert_eq!(err.errors[0].loc, vec!["headers".to_string(), "x-api-key".to_string()]);
2316    }
2317
2318    #[test]
2319    fn test_missing_required_cookie_reports_cookie_loc() {
2320        let schema = json!({
2321            "type": "object",
2322            "properties": {
2323                "session": {
2324                    "type": "string",
2325                    "source": "cookie"
2326                }
2327            },
2328            "required": ["session"]
2329        });
2330
2331        let validator = ParameterValidator::new(schema).unwrap();
2332
2333        let result = validator.validate_and_extract(
2334            &json!({}),
2335            &HashMap::new(),
2336            &HashMap::new(),
2337            &HashMap::new(),
2338            &HashMap::new(),
2339        );
2340
2341        assert!(result.is_err(), "expected missing cookie to fail");
2342        let err = result.unwrap_err();
2343        assert_eq!(err.errors.len(), 1);
2344        assert_eq!(err.errors[0].error_type, "missing");
2345        assert_eq!(err.errors[0].loc, vec!["cookie".to_string(), "session".to_string()]);
2346    }
2347
2348    #[test]
2349    fn test_query_boolean_empty_string_coerces_to_false() {
2350        let schema = json!({
2351            "type": "object",
2352            "properties": {
2353                "flag": {
2354                    "type": "boolean",
2355                    "source": "query"
2356                }
2357            },
2358            "required": ["flag"]
2359        });
2360
2361        let validator = ParameterValidator::new(schema).unwrap();
2362        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2363        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
2364
2365        let result = validator.validate_and_extract(
2366            &json!({"flag": ""}),
2367            &raw_query_params,
2368            &HashMap::new(),
2369            &HashMap::new(),
2370            &HashMap::new(),
2371        );
2372
2373        assert!(result.is_ok(), "expected empty string to coerce");
2374        let extracted = result.unwrap();
2375        assert_eq!(extracted["flag"], json!(false));
2376    }
2377
2378    #[test]
2379    fn test_query_array_wraps_scalar_value_and_coerces_items() {
2380        let schema = json!({
2381            "type": "object",
2382            "properties": {
2383                "ids": {
2384                    "type": "array",
2385                    "items": {"type": "integer"},
2386                    "source": "query"
2387                }
2388            },
2389            "required": ["ids"]
2390        });
2391
2392        let validator = ParameterValidator::new(schema).unwrap();
2393
2394        let result = validator.validate_and_extract(
2395            &json!({"ids": "1"}),
2396            &HashMap::new(),
2397            &HashMap::new(),
2398            &HashMap::new(),
2399            &HashMap::new(),
2400        );
2401
2402        assert!(result.is_ok(), "expected scalar query value to coerce into array");
2403        let extracted = result.unwrap();
2404        assert_eq!(extracted["ids"], json!([1]));
2405    }
2406
2407    #[test]
2408    fn test_query_array_invalid_item_returns_parsing_error() {
2409        let schema = json!({
2410            "type": "object",
2411            "properties": {
2412                "ids": {
2413                    "type": "array",
2414                    "items": {"type": "integer"},
2415                    "source": "query"
2416                }
2417            },
2418            "required": ["ids"]
2419        });
2420
2421        let validator = ParameterValidator::new(schema).unwrap();
2422
2423        let result = validator.validate_and_extract(
2424            &json!({"ids": ["x"]}),
2425            &HashMap::new(),
2426            &HashMap::new(),
2427            &HashMap::new(),
2428            &HashMap::new(),
2429        );
2430
2431        assert!(result.is_err(), "expected invalid array item to fail");
2432        let err = result.unwrap_err();
2433        assert_eq!(err.errors.len(), 1);
2434        assert_eq!(err.errors[0].error_type, "int_parsing");
2435        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "ids".to_string()]);
2436    }
2437
2438    #[test]
2439    fn test_uuid_date_datetime_time_and_duration_formats() {
2440        let schema = json!({
2441            "type": "object",
2442            "properties": {
2443                "id": {
2444                    "type": "string",
2445                    "format": "uuid",
2446                    "source": "path"
2447                },
2448                "date": {
2449                    "type": "string",
2450                    "format": "date",
2451                    "source": "query"
2452                },
2453                "dt": {
2454                    "type": "string",
2455                    "format": "date-time",
2456                    "source": "query"
2457                },
2458                "time": {
2459                    "type": "string",
2460                    "format": "time",
2461                    "source": "query"
2462                },
2463                "duration": {
2464                    "type": "string",
2465                    "format": "duration",
2466                    "source": "query"
2467                }
2468            },
2469            "required": ["id", "date", "dt", "time", "duration"]
2470        });
2471
2472        let validator = ParameterValidator::new(schema).unwrap();
2473
2474        let mut path_params = HashMap::new();
2475        path_params.insert("id".to_string(), "550e8400-e29b-41d4-a716-446655440000".to_string());
2476
2477        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2478        raw_query_params.insert("date".to_string(), vec!["2025-01-02".to_string()]);
2479        raw_query_params.insert("dt".to_string(), vec!["2025-01-02T03:04:05Z".to_string()]);
2480        raw_query_params.insert("time".to_string(), vec!["03:04:05Z".to_string()]);
2481        raw_query_params.insert("duration".to_string(), vec!["PT1S".to_string()]);
2482
2483        let result = validator.validate_and_extract(
2484            &json!({
2485                "date": "2025-01-02",
2486                "dt": "2025-01-02T03:04:05Z",
2487                "time": "03:04:05Z",
2488                "duration": "PT1S"
2489            }),
2490            &raw_query_params,
2491            &path_params,
2492            &HashMap::new(),
2493            &HashMap::new(),
2494        );
2495        assert!(result.is_ok(), "expected all format values to validate: {result:?}");
2496    }
2497
2498    #[test]
2499    fn test_optional_fields_are_not_required_in_validation_schema() {
2500        let schema = json!({
2501            "type": "object",
2502            "properties": {
2503                "maybe": {
2504                    "type": "string",
2505                    "source": "query",
2506                    "optional": true
2507                }
2508            },
2509            "required": ["maybe"]
2510        });
2511
2512        let validator = ParameterValidator::new(schema).unwrap();
2513        let result = validator.validate_and_extract(
2514            &json!({}),
2515            &HashMap::new(),
2516            &HashMap::new(),
2517            &HashMap::new(),
2518            &HashMap::new(),
2519        );
2520
2521        assert!(result.is_ok(), "optional field in required list should not fail");
2522        let extracted = result.unwrap();
2523        assert_eq!(extracted, json!({}));
2524    }
2525}