Skip to main content

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