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!(
962            result.is_ok(),
963            "Validation should succeed for boolean true: {:?}",
964            result
965        );
966        let params = result.unwrap();
967        assert_eq!(params, json!({"flag": true}));
968
969        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
970        raw_query_params.insert("flag".to_string(), vec!["false".to_string()]);
971        let query_params = json!({"flag": false});
972        let result = validator.validate_and_extract(
973            &query_params,
974            &raw_query_params,
975            &path_params,
976            &HashMap::new(),
977            &HashMap::new(),
978        );
979        assert!(
980            result.is_ok(),
981            "Validation should succeed for boolean false: {:?}",
982            result
983        );
984        let params = result.unwrap();
985        assert_eq!(params, json!({"flag": false}));
986    }
987
988    #[test]
989    fn test_integer_coercion_invalid_format_returns_error() {
990        let schema = json!({
991            "type": "object",
992            "properties": {
993                "count": {
994                    "type": "integer",
995                    "source": "query"
996                }
997            },
998            "required": ["count"]
999        });
1000
1001        let validator = ParameterValidator::new(schema).unwrap();
1002        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1003        raw_query_params.insert("count".to_string(), vec!["not_a_number".to_string()]);
1004
1005        let result = validator.validate_and_extract(
1006            &json!({"count": "not_a_number"}),
1007            &raw_query_params,
1008            &HashMap::new(),
1009            &HashMap::new(),
1010            &HashMap::new(),
1011        );
1012
1013        assert!(result.is_err(), "Should fail to coerce non-integer string");
1014        let err = result.unwrap_err();
1015        assert_eq!(err.errors.len(), 1);
1016        assert_eq!(err.errors[0].error_type, "int_parsing");
1017        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "count".to_string()]);
1018        assert!(err.errors[0].msg.contains("valid integer"));
1019    }
1020
1021    #[test]
1022    fn test_integer_coercion_with_letters_mixed_returns_error() {
1023        let schema = json!({
1024            "type": "object",
1025            "properties": {
1026                "id": {
1027                    "type": "integer",
1028                    "source": "path"
1029                }
1030            },
1031            "required": ["id"]
1032        });
1033
1034        let validator = ParameterValidator::new(schema).unwrap();
1035        let mut path_params = HashMap::new();
1036        path_params.insert("id".to_string(), "123abc".to_string());
1037
1038        let result = validator.validate_and_extract(
1039            &json!({}),
1040            &HashMap::new(),
1041            &path_params,
1042            &HashMap::new(),
1043            &HashMap::new(),
1044        );
1045
1046        assert!(result.is_err());
1047        let err = result.unwrap_err();
1048        assert_eq!(err.errors[0].error_type, "int_parsing");
1049    }
1050
1051    #[test]
1052    fn test_integer_coercion_overflow_returns_error() {
1053        let schema = json!({
1054            "type": "object",
1055            "properties": {
1056                "big_num": {
1057                    "type": "integer",
1058                    "source": "query"
1059                }
1060            },
1061            "required": ["big_num"]
1062        });
1063
1064        let validator = ParameterValidator::new(schema).unwrap();
1065        let too_large = "9223372036854775808";
1066        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1067        raw_query_params.insert("big_num".to_string(), vec![too_large.to_string()]);
1068
1069        let result = validator.validate_and_extract(
1070            &json!({"big_num": too_large}),
1071            &raw_query_params,
1072            &HashMap::new(),
1073            &HashMap::new(),
1074            &HashMap::new(),
1075        );
1076
1077        assert!(result.is_err(), "Should fail on integer overflow");
1078        let err = result.unwrap_err();
1079        assert_eq!(err.errors[0].error_type, "int_parsing");
1080    }
1081
1082    #[test]
1083    fn test_integer_coercion_negative_overflow_returns_error() {
1084        let schema = json!({
1085            "type": "object",
1086            "properties": {
1087                "small_num": {
1088                    "type": "integer",
1089                    "source": "query"
1090                }
1091            },
1092            "required": ["small_num"]
1093        });
1094
1095        let validator = ParameterValidator::new(schema).unwrap();
1096        let too_small = "-9223372036854775809";
1097        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1098        raw_query_params.insert("small_num".to_string(), vec![too_small.to_string()]);
1099
1100        let result = validator.validate_and_extract(
1101            &json!({"small_num": too_small}),
1102            &raw_query_params,
1103            &HashMap::new(),
1104            &HashMap::new(),
1105            &HashMap::new(),
1106        );
1107
1108        assert!(result.is_err());
1109        let err = result.unwrap_err();
1110        assert_eq!(err.errors[0].error_type, "int_parsing");
1111    }
1112
1113    #[test]
1114    fn test_optional_field_overrides_required_list() {
1115        let schema = json!({
1116            "type": "object",
1117            "properties": {
1118                "maybe": {
1119                    "type": "string",
1120                    "source": "query",
1121                    "optional": true
1122                }
1123            },
1124            "required": ["maybe"]
1125        });
1126
1127        let validator = ParameterValidator::new(schema).unwrap();
1128
1129        let result = validator.validate_and_extract(
1130            &json!({}),
1131            &HashMap::new(),
1132            &HashMap::new(),
1133            &HashMap::new(),
1134            &HashMap::new(),
1135        );
1136
1137        assert!(result.is_ok(), "optional required field should not error: {result:?}");
1138        assert_eq!(result.unwrap(), json!({}));
1139    }
1140
1141    #[test]
1142    fn test_header_name_is_normalized_for_lookup_and_errors() {
1143        let schema = json!({
1144            "type": "object",
1145            "properties": {
1146                "x_request_id": {
1147                    "type": "string",
1148                    "source": "header"
1149                }
1150            },
1151            "required": ["x_request_id"]
1152        });
1153
1154        let validator = ParameterValidator::new(schema).unwrap();
1155
1156        let mut headers = HashMap::new();
1157        headers.insert("x-request-id".to_string(), "abc123".to_string());
1158
1159        let ok = validator
1160            .validate_and_extract(&json!({}), &HashMap::new(), &HashMap::new(), &headers, &HashMap::new())
1161            .unwrap();
1162        assert_eq!(ok, json!({"x_request_id": "abc123"}));
1163
1164        let err = validator
1165            .validate_and_extract(
1166                &json!({}),
1167                &HashMap::new(),
1168                &HashMap::new(),
1169                &HashMap::new(),
1170                &HashMap::new(),
1171            )
1172            .unwrap_err();
1173        assert_eq!(
1174            err.errors[0].loc,
1175            vec!["headers".to_string(), "x-request-id".to_string()]
1176        );
1177    }
1178
1179    #[test]
1180    fn test_boolean_empty_string_coerces_to_false() {
1181        let schema = json!({
1182            "type": "object",
1183            "properties": {
1184                "flag": {
1185                    "type": "boolean",
1186                    "source": "query"
1187                }
1188            },
1189            "required": ["flag"]
1190        });
1191
1192        let validator = ParameterValidator::new(schema).unwrap();
1193        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1194        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
1195
1196        let result = validator
1197            .validate_and_extract(
1198                &json!({"flag": ""}),
1199                &raw_query_params,
1200                &HashMap::new(),
1201                &HashMap::new(),
1202                &HashMap::new(),
1203            )
1204            .unwrap();
1205        assert_eq!(result, json!({"flag": false}));
1206    }
1207
1208    #[test]
1209    fn test_uuid_format_validation_returns_uuid_parsing_error() {
1210        let schema = json!({
1211            "type": "object",
1212            "properties": {
1213                "id": {
1214                    "type": "string",
1215                    "format": "uuid",
1216                    "source": "query"
1217                }
1218            },
1219            "required": ["id"]
1220        });
1221
1222        let validator = ParameterValidator::new(schema).unwrap();
1223        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1224        raw_query_params.insert("id".to_string(), vec!["not-a-uuid".to_string()]);
1225
1226        let err = validator
1227            .validate_and_extract(
1228                &json!({"id": "not-a-uuid"}),
1229                &raw_query_params,
1230                &HashMap::new(),
1231                &HashMap::new(),
1232                &HashMap::new(),
1233            )
1234            .unwrap_err();
1235
1236        assert_eq!(err.errors[0].error_type, "uuid_parsing");
1237        assert!(
1238            err.errors[0].msg.contains("valid UUID"),
1239            "msg was {}",
1240            err.errors[0].msg
1241        );
1242    }
1243
1244    #[test]
1245    fn test_array_query_parameter_coercion_error_reports_item_parse_failure() {
1246        let schema = json!({
1247            "type": "object",
1248            "properties": {
1249                "ids": {
1250                    "type": "array",
1251                    "items": {"type": "integer"},
1252                    "source": "query"
1253                }
1254            },
1255            "required": ["ids"]
1256        });
1257
1258        let validator = ParameterValidator::new(schema).unwrap();
1259        let query_params = json!({ "ids": ["nope"] });
1260
1261        let err = validator
1262            .validate_and_extract(
1263                &query_params,
1264                &HashMap::new(),
1265                &HashMap::new(),
1266                &HashMap::new(),
1267                &HashMap::new(),
1268            )
1269            .unwrap_err();
1270
1271        assert_eq!(err.errors[0].error_type, "int_parsing");
1272        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "ids".to_string()]);
1273    }
1274
1275    #[test]
1276    fn test_float_coercion_invalid_format_returns_error() {
1277        let schema = json!({
1278            "type": "object",
1279            "properties": {
1280                "price": {
1281                    "type": "number",
1282                    "source": "query"
1283                }
1284            },
1285            "required": ["price"]
1286        });
1287
1288        let validator = ParameterValidator::new(schema).unwrap();
1289        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1290        raw_query_params.insert("price".to_string(), vec!["not.a.number".to_string()]);
1291
1292        let result = validator.validate_and_extract(
1293            &json!({"price": "not.a.number"}),
1294            &raw_query_params,
1295            &HashMap::new(),
1296            &HashMap::new(),
1297            &HashMap::new(),
1298        );
1299
1300        assert!(result.is_err());
1301        let err = result.unwrap_err();
1302        assert_eq!(err.errors[0].error_type, "float_parsing");
1303        assert!(err.errors[0].msg.contains("valid number"));
1304    }
1305
1306    #[test]
1307    fn test_float_coercion_scientific_notation_success() {
1308        let schema = json!({
1309            "type": "object",
1310            "properties": {
1311                "value": {
1312                    "type": "number",
1313                    "source": "query"
1314                }
1315            },
1316            "required": ["value"]
1317        });
1318
1319        let validator = ParameterValidator::new(schema).unwrap();
1320        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1321        raw_query_params.insert("value".to_string(), vec!["1.5e10".to_string()]);
1322
1323        let result = validator.validate_and_extract(
1324            &json!({"value": 1.5e10}),
1325            &raw_query_params,
1326            &HashMap::new(),
1327            &HashMap::new(),
1328            &HashMap::new(),
1329        );
1330
1331        assert!(result.is_ok());
1332        let extracted = result.unwrap();
1333        assert_eq!(extracted["value"], json!(1.5e10));
1334    }
1335
1336    #[test]
1337    fn test_boolean_coercion_empty_string_returns_false() {
1338        // BUG: Empty string returns false instead of error - this is behavior to verify
1339        let schema = json!({
1340            "type": "object",
1341            "properties": {
1342                "flag": {
1343                    "type": "boolean",
1344                    "source": "query"
1345                }
1346            },
1347            "required": ["flag"]
1348        });
1349
1350        let validator = ParameterValidator::new(schema).unwrap();
1351        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1352        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
1353
1354        let result = validator.validate_and_extract(
1355            &json!({"flag": ""}),
1356            &raw_query_params,
1357            &HashMap::new(),
1358            &HashMap::new(),
1359            &HashMap::new(),
1360        );
1361
1362        assert!(result.is_ok());
1363        let extracted = result.unwrap();
1364        assert_eq!(extracted["flag"], json!(false));
1365    }
1366
1367    #[test]
1368    fn test_boolean_coercion_whitespace_string_returns_error() {
1369        let schema = json!({
1370            "type": "object",
1371            "properties": {
1372                "flag": {
1373                    "type": "boolean",
1374                    "source": "query"
1375                }
1376            },
1377            "required": ["flag"]
1378        });
1379
1380        let validator = ParameterValidator::new(schema).unwrap();
1381        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1382        raw_query_params.insert("flag".to_string(), vec!["   ".to_string()]);
1383
1384        let result = validator.validate_and_extract(
1385            &json!({"flag": "   "}),
1386            &raw_query_params,
1387            &HashMap::new(),
1388            &HashMap::new(),
1389            &HashMap::new(),
1390        );
1391
1392        assert!(result.is_err(), "Whitespace-only string should fail boolean parsing");
1393        let err = result.unwrap_err();
1394        assert_eq!(err.errors[0].error_type, "bool_parsing");
1395    }
1396
1397    #[test]
1398    fn test_boolean_coercion_invalid_value_returns_error() {
1399        let schema = json!({
1400            "type": "object",
1401            "properties": {
1402                "enabled": {
1403                    "type": "boolean",
1404                    "source": "path"
1405                }
1406            },
1407            "required": ["enabled"]
1408        });
1409
1410        let validator = ParameterValidator::new(schema).unwrap();
1411        let mut path_params = HashMap::new();
1412        path_params.insert("enabled".to_string(), "maybe".to_string());
1413
1414        let result = validator.validate_and_extract(
1415            &json!({}),
1416            &HashMap::new(),
1417            &path_params,
1418            &HashMap::new(),
1419            &HashMap::new(),
1420        );
1421
1422        assert!(result.is_err());
1423        let err = result.unwrap_err();
1424        assert_eq!(err.errors[0].error_type, "bool_parsing");
1425        assert!(err.errors[0].msg.contains("valid boolean"));
1426    }
1427
1428    #[test]
1429    fn test_required_query_parameter_missing_returns_error() {
1430        let schema = json!({
1431            "type": "object",
1432            "properties": {
1433                "required_param": {
1434                    "type": "string",
1435                    "source": "query"
1436                }
1437            },
1438            "required": ["required_param"]
1439        });
1440
1441        let validator = ParameterValidator::new(schema).unwrap();
1442
1443        let result = validator.validate_and_extract(
1444            &json!({}),
1445            &HashMap::new(),
1446            &HashMap::new(),
1447            &HashMap::new(),
1448            &HashMap::new(),
1449        );
1450
1451        assert!(result.is_err());
1452        let err = result.unwrap_err();
1453        assert_eq!(err.errors[0].error_type, "missing");
1454        assert_eq!(
1455            err.errors[0].loc,
1456            vec!["query".to_string(), "required_param".to_string()]
1457        );
1458        assert!(err.errors[0].msg.contains("required"));
1459    }
1460
1461    #[test]
1462    fn test_required_path_parameter_missing_returns_error() {
1463        let schema = json!({
1464            "type": "object",
1465            "properties": {
1466                "user_id": {
1467                    "type": "string",
1468                    "source": "path"
1469                }
1470            },
1471            "required": ["user_id"]
1472        });
1473
1474        let validator = ParameterValidator::new(schema).unwrap();
1475
1476        let result = validator.validate_and_extract(
1477            &json!({}),
1478            &HashMap::new(),
1479            &HashMap::new(),
1480            &HashMap::new(),
1481            &HashMap::new(),
1482        );
1483
1484        assert!(result.is_err());
1485        let err = result.unwrap_err();
1486        assert_eq!(err.errors[0].error_type, "missing");
1487        assert_eq!(err.errors[0].loc, vec!["path".to_string(), "user_id".to_string()]);
1488    }
1489
1490    #[test]
1491    fn test_required_header_parameter_missing_returns_error() {
1492        let schema = json!({
1493            "type": "object",
1494            "properties": {
1495                "Authorization": {
1496                    "type": "string",
1497                    "source": "header"
1498                }
1499            },
1500            "required": ["Authorization"]
1501        });
1502
1503        let validator = ParameterValidator::new(schema).unwrap();
1504
1505        let result = validator.validate_and_extract(
1506            &json!({}),
1507            &HashMap::new(),
1508            &HashMap::new(),
1509            &HashMap::new(),
1510            &HashMap::new(),
1511        );
1512
1513        assert!(result.is_err());
1514        let err = result.unwrap_err();
1515        assert_eq!(err.errors[0].error_type, "missing");
1516        assert_eq!(
1517            err.errors[0].loc,
1518            vec!["headers".to_string(), "authorization".to_string()]
1519        );
1520    }
1521
1522    #[test]
1523    fn test_required_cookie_parameter_missing_returns_error() {
1524        let schema = json!({
1525            "type": "object",
1526            "properties": {
1527                "session_id": {
1528                    "type": "string",
1529                    "source": "cookie"
1530                }
1531            },
1532            "required": ["session_id"]
1533        });
1534
1535        let validator = ParameterValidator::new(schema).unwrap();
1536
1537        let result = validator.validate_and_extract(
1538            &json!({}),
1539            &HashMap::new(),
1540            &HashMap::new(),
1541            &HashMap::new(),
1542            &HashMap::new(),
1543        );
1544
1545        assert!(result.is_err());
1546        let err = result.unwrap_err();
1547        assert_eq!(err.errors[0].error_type, "missing");
1548        assert_eq!(err.errors[0].loc, vec!["cookie".to_string(), "session_id".to_string()]);
1549    }
1550
1551    #[test]
1552    fn test_optional_parameter_missing_succeeds() {
1553        let schema = json!({
1554            "type": "object",
1555            "properties": {
1556                "optional_param": {
1557                    "type": "string",
1558                    "source": "query",
1559                    "optional": true
1560                }
1561            },
1562            "required": []
1563        });
1564
1565        let validator = ParameterValidator::new(schema).unwrap();
1566
1567        let result = validator.validate_and_extract(
1568            &json!({}),
1569            &HashMap::new(),
1570            &HashMap::new(),
1571            &HashMap::new(),
1572            &HashMap::new(),
1573        );
1574
1575        assert!(result.is_ok(), "Optional parameter should not cause error when missing");
1576        let extracted = result.unwrap();
1577        assert!(!extracted.as_object().unwrap().contains_key("optional_param"));
1578    }
1579
1580    #[test]
1581    fn test_uuid_validation_invalid_format_returns_error() {
1582        let schema = json!({
1583            "type": "object",
1584            "properties": {
1585                "id": {
1586                    "type": "string",
1587                    "format": "uuid",
1588                    "source": "path"
1589                }
1590            },
1591            "required": ["id"]
1592        });
1593
1594        let validator = ParameterValidator::new(schema).unwrap();
1595        let mut path_params = HashMap::new();
1596        path_params.insert("id".to_string(), "not-a-uuid".to_string());
1597
1598        let result = validator.validate_and_extract(
1599            &json!({}),
1600            &HashMap::new(),
1601            &path_params,
1602            &HashMap::new(),
1603            &HashMap::new(),
1604        );
1605
1606        assert!(result.is_err());
1607        let err = result.unwrap_err();
1608        assert_eq!(err.errors[0].error_type, "uuid_parsing");
1609        assert!(err.errors[0].msg.contains("UUID"));
1610    }
1611
1612    #[test]
1613    fn test_uuid_validation_uppercase_succeeds() {
1614        let schema = json!({
1615            "type": "object",
1616            "properties": {
1617                "id": {
1618                    "type": "string",
1619                    "format": "uuid",
1620                    "source": "query"
1621                }
1622            },
1623            "required": ["id"]
1624        });
1625
1626        let validator = ParameterValidator::new(schema).unwrap();
1627        let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
1628        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1629        raw_query_params.insert("id".to_string(), vec![valid_uuid.to_string()]);
1630
1631        let result = validator.validate_and_extract(
1632            &json!({"id": valid_uuid}),
1633            &raw_query_params,
1634            &HashMap::new(),
1635            &HashMap::new(),
1636            &HashMap::new(),
1637        );
1638
1639        assert!(result.is_ok());
1640        let extracted = result.unwrap();
1641        assert_eq!(extracted["id"], json!(valid_uuid));
1642    }
1643
1644    #[test]
1645    fn test_date_validation_invalid_format_returns_error() {
1646        let schema = json!({
1647            "type": "object",
1648            "properties": {
1649                "created_at": {
1650                    "type": "string",
1651                    "format": "date",
1652                    "source": "query"
1653                }
1654            },
1655            "required": ["created_at"]
1656        });
1657
1658        let validator = ParameterValidator::new(schema).unwrap();
1659        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1660        raw_query_params.insert("created_at".to_string(), vec!["2024/12/10".to_string()]);
1661
1662        let result = validator.validate_and_extract(
1663            &json!({"created_at": "2024/12/10"}),
1664            &raw_query_params,
1665            &HashMap::new(),
1666            &HashMap::new(),
1667            &HashMap::new(),
1668        );
1669
1670        assert!(result.is_err());
1671        let err = result.unwrap_err();
1672        assert_eq!(err.errors[0].error_type, "date_parsing");
1673        assert!(err.errors[0].msg.contains("date"));
1674    }
1675
1676    #[test]
1677    fn test_date_validation_valid_iso_succeeds() {
1678        let schema = json!({
1679            "type": "object",
1680            "properties": {
1681                "created_at": {
1682                    "type": "string",
1683                    "format": "date",
1684                    "source": "query"
1685                }
1686            },
1687            "required": ["created_at"]
1688        });
1689
1690        let validator = ParameterValidator::new(schema).unwrap();
1691        let valid_date = "2024-12-10";
1692        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1693        raw_query_params.insert("created_at".to_string(), vec![valid_date.to_string()]);
1694
1695        let result = validator.validate_and_extract(
1696            &json!({"created_at": valid_date}),
1697            &raw_query_params,
1698            &HashMap::new(),
1699            &HashMap::new(),
1700            &HashMap::new(),
1701        );
1702
1703        assert!(result.is_ok());
1704        let extracted = result.unwrap();
1705        assert_eq!(extracted["created_at"], json!(valid_date));
1706    }
1707
1708    #[test]
1709    fn test_datetime_validation_invalid_format_returns_error() {
1710        let schema = json!({
1711            "type": "object",
1712            "properties": {
1713                "timestamp": {
1714                    "type": "string",
1715                    "format": "date-time",
1716                    "source": "query"
1717                }
1718            },
1719            "required": ["timestamp"]
1720        });
1721
1722        let validator = ParameterValidator::new(schema).unwrap();
1723        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1724        raw_query_params.insert("timestamp".to_string(), vec!["not-a-datetime".to_string()]);
1725
1726        let result = validator.validate_and_extract(
1727            &json!({"timestamp": "not-a-datetime"}),
1728            &raw_query_params,
1729            &HashMap::new(),
1730            &HashMap::new(),
1731            &HashMap::new(),
1732        );
1733
1734        assert!(result.is_err());
1735        let err = result.unwrap_err();
1736        assert_eq!(err.errors[0].error_type, "datetime_parsing");
1737    }
1738
1739    #[test]
1740    fn test_time_validation_invalid_format_returns_error() {
1741        let schema = json!({
1742            "type": "object",
1743            "properties": {
1744                "start_time": {
1745                    "type": "string",
1746                    "format": "time",
1747                    "source": "query"
1748                }
1749            },
1750            "required": ["start_time"]
1751        });
1752
1753        let validator = ParameterValidator::new(schema).unwrap();
1754        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1755        raw_query_params.insert("start_time".to_string(), vec!["25:00:00".to_string()]);
1756
1757        let result = validator.validate_and_extract(
1758            &json!({"start_time": "25:00:00"}),
1759            &raw_query_params,
1760            &HashMap::new(),
1761            &HashMap::new(),
1762            &HashMap::new(),
1763        );
1764
1765        assert!(result.is_err());
1766        let err = result.unwrap_err();
1767        assert_eq!(err.errors[0].error_type, "time_parsing");
1768    }
1769
1770    #[test]
1771    fn test_time_validation_string_passthrough() {
1772        let schema = json!({
1773            "type": "object",
1774            "properties": {
1775                "start_time": {
1776                    "type": "string",
1777                    "source": "query"
1778                }
1779            },
1780            "required": ["start_time"]
1781        });
1782
1783        let validator = ParameterValidator::new(schema).unwrap();
1784        let time_string = "14:30:00";
1785        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1786        raw_query_params.insert("start_time".to_string(), vec![time_string.to_string()]);
1787
1788        let result = validator.validate_and_extract(
1789            &json!({"start_time": time_string}),
1790            &raw_query_params,
1791            &HashMap::new(),
1792            &HashMap::new(),
1793            &HashMap::new(),
1794        );
1795
1796        assert!(result.is_ok(), "String parameter should pass: {:?}", result);
1797        let extracted = result.unwrap();
1798        assert_eq!(extracted["start_time"], json!(time_string));
1799    }
1800
1801    #[test]
1802    fn test_duration_validation_invalid_format_returns_error() {
1803        let schema = json!({
1804            "type": "object",
1805            "properties": {
1806                "timeout": {
1807                    "type": "string",
1808                    "format": "duration",
1809                    "source": "query"
1810                }
1811            },
1812            "required": ["timeout"]
1813        });
1814
1815        let validator = ParameterValidator::new(schema).unwrap();
1816        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1817        raw_query_params.insert("timeout".to_string(), vec!["not-a-duration".to_string()]);
1818
1819        let result = validator.validate_and_extract(
1820            &json!({"timeout": "not-a-duration"}),
1821            &raw_query_params,
1822            &HashMap::new(),
1823            &HashMap::new(),
1824            &HashMap::new(),
1825        );
1826
1827        assert!(result.is_err());
1828        let err = result.unwrap_err();
1829        assert_eq!(err.errors[0].error_type, "duration_parsing");
1830    }
1831
1832    #[test]
1833    fn test_duration_validation_iso8601_succeeds() {
1834        let schema = json!({
1835            "type": "object",
1836            "properties": {
1837                "timeout": {
1838                    "type": "string",
1839                    "format": "duration",
1840                    "source": "query"
1841                }
1842            },
1843            "required": ["timeout"]
1844        });
1845
1846        let validator = ParameterValidator::new(schema).unwrap();
1847        let valid_duration = "PT5M";
1848        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1849        raw_query_params.insert("timeout".to_string(), vec![valid_duration.to_string()]);
1850
1851        let result = validator.validate_and_extract(
1852            &json!({"timeout": valid_duration}),
1853            &raw_query_params,
1854            &HashMap::new(),
1855            &HashMap::new(),
1856            &HashMap::new(),
1857        );
1858
1859        assert!(result.is_ok());
1860    }
1861
1862    #[test]
1863    fn test_header_name_normalization_with_underscores() {
1864        let schema = json!({
1865            "type": "object",
1866            "properties": {
1867                "X_Custom_Header": {
1868                    "type": "string",
1869                    "source": "header"
1870                }
1871            },
1872            "required": ["X_Custom_Header"]
1873        });
1874
1875        let validator = ParameterValidator::new(schema).unwrap();
1876        let mut headers = HashMap::new();
1877        headers.insert("x-custom-header".to_string(), "value".to_string());
1878
1879        let result =
1880            validator.validate_and_extract(&json!({}), &HashMap::new(), &HashMap::new(), &headers, &HashMap::new());
1881
1882        assert!(result.is_ok());
1883        let extracted = result.unwrap();
1884        assert_eq!(extracted["X_Custom_Header"], json!("value"));
1885    }
1886
1887    #[test]
1888    fn test_multiple_query_parameter_values_uses_first() {
1889        let schema = json!({
1890            "type": "object",
1891            "properties": {
1892                "id": {
1893                    "type": "integer",
1894                    "source": "query"
1895                }
1896            },
1897            "required": ["id"]
1898        });
1899
1900        let validator = ParameterValidator::new(schema).unwrap();
1901        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
1902        raw_query_params.insert("id".to_string(), vec!["123".to_string(), "456".to_string()]);
1903
1904        let result = validator.validate_and_extract(
1905            &json!({"id": [123, 456]}),
1906            &raw_query_params,
1907            &HashMap::new(),
1908            &HashMap::new(),
1909            &HashMap::new(),
1910        );
1911
1912        assert!(result.is_ok(), "Should accept first value of multiple query params");
1913        let extracted = result.unwrap();
1914        assert_eq!(extracted["id"], json!(123));
1915    }
1916
1917    #[test]
1918    fn test_schema_creation_missing_source_field_returns_error() {
1919        let schema = json!({
1920            "type": "object",
1921            "properties": {
1922                "param": {
1923                    "type": "string"
1924                }
1925            },
1926            "required": []
1927        });
1928
1929        let result = ParameterValidator::new(schema);
1930        assert!(result.is_err(), "Schema without 'source' field should fail");
1931        let err_msg = result.unwrap_err();
1932        assert!(err_msg.contains("source"));
1933    }
1934
1935    #[test]
1936    fn test_schema_creation_invalid_source_value_returns_error() {
1937        let schema = json!({
1938            "type": "object",
1939            "properties": {
1940                "param": {
1941                    "type": "string",
1942                    "source": "invalid_source"
1943                }
1944            },
1945            "required": []
1946        });
1947
1948        let result = ParameterValidator::new(schema);
1949        assert!(result.is_err());
1950        let err_msg = result.unwrap_err();
1951        assert!(err_msg.contains("Invalid source"));
1952    }
1953
1954    #[test]
1955    fn test_multiple_errors_reported_together() {
1956        let schema = json!({
1957            "type": "object",
1958            "properties": {
1959                "count": {
1960                    "type": "integer",
1961                    "source": "query"
1962                },
1963                "user_id": {
1964                    "type": "string",
1965                    "source": "path"
1966                },
1967                "token": {
1968                    "type": "string",
1969                    "source": "header"
1970                }
1971            },
1972            "required": ["count", "user_id", "token"]
1973        });
1974
1975        let validator = ParameterValidator::new(schema).unwrap();
1976
1977        let result = validator.validate_and_extract(
1978            &json!({}),
1979            &HashMap::new(),
1980            &HashMap::new(),
1981            &HashMap::new(),
1982            &HashMap::new(),
1983        );
1984
1985        assert!(result.is_err());
1986        let err = result.unwrap_err();
1987        assert_eq!(err.errors.len(), 3);
1988        assert!(err.errors.iter().all(|e| e.error_type == "missing"));
1989    }
1990
1991    #[test]
1992    fn test_coercion_error_includes_original_value() {
1993        let schema = json!({
1994            "type": "object",
1995            "properties": {
1996                "age": {
1997                    "type": "integer",
1998                    "source": "query"
1999                }
2000            },
2001            "required": ["age"]
2002        });
2003
2004        let validator = ParameterValidator::new(schema).unwrap();
2005        let invalid_value = "not_an_int";
2006        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2007        raw_query_params.insert("age".to_string(), vec![invalid_value.to_string()]);
2008
2009        let result = validator.validate_and_extract(
2010            &json!({"age": invalid_value}),
2011            &raw_query_params,
2012            &HashMap::new(),
2013            &HashMap::new(),
2014            &HashMap::new(),
2015        );
2016
2017        assert!(result.is_err());
2018        let err = result.unwrap_err();
2019        assert_eq!(err.errors[0].input, json!(invalid_value));
2020    }
2021
2022    #[test]
2023    fn test_string_parameter_passes_through() {
2024        let schema = json!({
2025            "type": "object",
2026            "properties": {
2027                "name": {
2028                    "type": "string",
2029                    "source": "query"
2030                }
2031            },
2032            "required": ["name"]
2033        });
2034
2035        let validator = ParameterValidator::new(schema).unwrap();
2036        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2037        raw_query_params.insert("name".to_string(), vec!["Alice".to_string()]);
2038
2039        let result = validator.validate_and_extract(
2040            &json!({"name": "Alice"}),
2041            &raw_query_params,
2042            &HashMap::new(),
2043            &HashMap::new(),
2044            &HashMap::new(),
2045        );
2046
2047        assert!(result.is_ok());
2048        let extracted = result.unwrap();
2049        assert_eq!(extracted["name"], json!("Alice"));
2050    }
2051
2052    #[test]
2053    fn test_string_with_special_characters_passes_through() {
2054        let schema = json!({
2055            "type": "object",
2056            "properties": {
2057                "message": {
2058                    "type": "string",
2059                    "source": "query"
2060                }
2061            },
2062            "required": ["message"]
2063        });
2064
2065        let validator = ParameterValidator::new(schema).unwrap();
2066        let special_value = "Hello! @#$%^&*() Unicode: 你好";
2067        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2068        raw_query_params.insert("message".to_string(), vec![special_value.to_string()]);
2069
2070        let result = validator.validate_and_extract(
2071            &json!({"message": special_value}),
2072            &raw_query_params,
2073            &HashMap::new(),
2074            &HashMap::new(),
2075            &HashMap::new(),
2076        );
2077
2078        assert!(result.is_ok());
2079        let extracted = result.unwrap();
2080        assert_eq!(extracted["message"], json!(special_value));
2081    }
2082
2083    #[test]
2084    fn test_array_query_parameter_missing_required_returns_error() {
2085        let schema = json!({
2086            "type": "object",
2087            "properties": {
2088                "ids": {
2089                    "type": "array",
2090                    "items": {"type": "integer"},
2091                    "source": "query"
2092                }
2093            },
2094            "required": ["ids"]
2095        });
2096
2097        let validator = ParameterValidator::new(schema).unwrap();
2098
2099        let result = validator.validate_and_extract(
2100            &json!({}),
2101            &HashMap::new(),
2102            &HashMap::new(),
2103            &HashMap::new(),
2104            &HashMap::new(),
2105        );
2106
2107        assert!(result.is_err());
2108        let err = result.unwrap_err();
2109        assert_eq!(err.errors[0].error_type, "missing");
2110    }
2111
2112    #[test]
2113    fn test_empty_array_parameter_accepted() {
2114        let schema = json!({
2115            "type": "object",
2116            "properties": {
2117                "tags": {
2118                    "type": "array",
2119                    "items": {"type": "string"},
2120                    "source": "query"
2121                }
2122            },
2123            "required": ["tags"]
2124        });
2125
2126        let validator = ParameterValidator::new(schema).unwrap();
2127
2128        let result = validator.validate_and_extract(
2129            &json!({"tags": []}),
2130            &HashMap::new(),
2131            &HashMap::new(),
2132            &HashMap::new(),
2133            &HashMap::new(),
2134        );
2135
2136        assert!(result.is_ok());
2137        let extracted = result.unwrap();
2138        assert_eq!(extracted["tags"], json!([]));
2139    }
2140
2141    #[test]
2142    fn test_parameter_source_from_str_query() {
2143        assert_eq!(ParameterSource::from_str("query"), Some(ParameterSource::Query));
2144    }
2145
2146    #[test]
2147    fn test_parameter_source_from_str_path() {
2148        assert_eq!(ParameterSource::from_str("path"), Some(ParameterSource::Path));
2149    }
2150
2151    #[test]
2152    fn test_parameter_source_from_str_header() {
2153        assert_eq!(ParameterSource::from_str("header"), Some(ParameterSource::Header));
2154    }
2155
2156    #[test]
2157    fn test_parameter_source_from_str_cookie() {
2158        assert_eq!(ParameterSource::from_str("cookie"), Some(ParameterSource::Cookie));
2159    }
2160
2161    #[test]
2162    fn test_parameter_source_from_str_invalid() {
2163        assert_eq!(ParameterSource::from_str("invalid"), None);
2164    }
2165
2166    #[test]
2167    fn test_integer_with_plus_sign() {
2168        let schema = json!({
2169            "type": "object",
2170            "properties": {
2171                "count": {
2172                    "type": "integer",
2173                    "source": "query"
2174                }
2175            },
2176            "required": ["count"]
2177        });
2178
2179        let validator = ParameterValidator::new(schema).unwrap();
2180        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2181        raw_query_params.insert("count".to_string(), vec!["+123".to_string()]);
2182
2183        let result = validator.validate_and_extract(
2184            &json!({"count": "+123"}),
2185            &raw_query_params,
2186            &HashMap::new(),
2187            &HashMap::new(),
2188            &HashMap::new(),
2189        );
2190
2191        assert!(result.is_ok());
2192        let extracted = result.unwrap();
2193        assert_eq!(extracted["count"], json!(123));
2194    }
2195
2196    #[test]
2197    fn test_float_with_leading_dot() {
2198        let schema = json!({
2199            "type": "object",
2200            "properties": {
2201                "ratio": {
2202                    "type": "number",
2203                    "source": "query"
2204                }
2205            },
2206            "required": ["ratio"]
2207        });
2208
2209        let validator = ParameterValidator::new(schema).unwrap();
2210        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2211        raw_query_params.insert("ratio".to_string(), vec![".5".to_string()]);
2212
2213        let result = validator.validate_and_extract(
2214            &json!({"ratio": 0.5}),
2215            &raw_query_params,
2216            &HashMap::new(),
2217            &HashMap::new(),
2218            &HashMap::new(),
2219        );
2220
2221        assert!(result.is_ok());
2222        let extracted = result.unwrap();
2223        assert_eq!(extracted["ratio"], json!(0.5));
2224    }
2225
2226    #[test]
2227    fn test_float_with_trailing_dot() {
2228        let schema = json!({
2229            "type": "object",
2230            "properties": {
2231                "value": {
2232                    "type": "number",
2233                    "source": "query"
2234                }
2235            },
2236            "required": ["value"]
2237        });
2238
2239        let validator = ParameterValidator::new(schema).unwrap();
2240        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2241        raw_query_params.insert("value".to_string(), vec!["5.".to_string()]);
2242
2243        let result = validator.validate_and_extract(
2244            &json!({"value": 5.0}),
2245            &raw_query_params,
2246            &HashMap::new(),
2247            &HashMap::new(),
2248            &HashMap::new(),
2249        );
2250
2251        assert!(result.is_ok());
2252    }
2253
2254    #[test]
2255    fn test_boolean_case_insensitive_true() {
2256        let schema = json!({
2257            "type": "object",
2258            "properties": {
2259                "flag": {
2260                    "type": "boolean",
2261                    "source": "query"
2262                }
2263            },
2264            "required": ["flag"]
2265        });
2266
2267        let validator = ParameterValidator::new(schema).unwrap();
2268        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2269        raw_query_params.insert("flag".to_string(), vec!["TrUe".to_string()]);
2270
2271        let result = validator.validate_and_extract(
2272            &json!({"flag": true}),
2273            &raw_query_params,
2274            &HashMap::new(),
2275            &HashMap::new(),
2276            &HashMap::new(),
2277        );
2278
2279        assert!(result.is_ok());
2280        let extracted = result.unwrap();
2281        assert_eq!(extracted["flag"], json!(true));
2282    }
2283
2284    #[test]
2285    fn test_boolean_case_insensitive_false() {
2286        let schema = json!({
2287            "type": "object",
2288            "properties": {
2289                "flag": {
2290                    "type": "boolean",
2291                    "source": "query"
2292                }
2293            },
2294            "required": ["flag"]
2295        });
2296
2297        let validator = ParameterValidator::new(schema).unwrap();
2298        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2299        raw_query_params.insert("flag".to_string(), vec!["FaLsE".to_string()]);
2300
2301        let result = validator.validate_and_extract(
2302            &json!({"flag": false}),
2303            &raw_query_params,
2304            &HashMap::new(),
2305            &HashMap::new(),
2306            &HashMap::new(),
2307        );
2308
2309        assert!(result.is_ok());
2310        let extracted = result.unwrap();
2311        assert_eq!(extracted["flag"], json!(false));
2312    }
2313
2314    #[test]
2315    fn test_missing_required_header_uses_kebab_case_in_error_loc() {
2316        let schema = json!({
2317            "type": "object",
2318            "properties": {
2319                "x_api_key": {
2320                    "type": "string",
2321                    "source": "header"
2322                }
2323            },
2324            "required": ["x_api_key"]
2325        });
2326
2327        let validator = ParameterValidator::new(schema).unwrap();
2328
2329        let result = validator.validate_and_extract(
2330            &json!({}),
2331            &HashMap::new(),
2332            &HashMap::new(),
2333            &HashMap::new(),
2334            &HashMap::new(),
2335        );
2336
2337        assert!(result.is_err(), "expected missing header to fail");
2338        let err = result.unwrap_err();
2339        assert_eq!(err.errors.len(), 1);
2340        assert_eq!(err.errors[0].error_type, "missing");
2341        assert_eq!(err.errors[0].loc, vec!["headers".to_string(), "x-api-key".to_string()]);
2342    }
2343
2344    #[test]
2345    fn test_missing_required_cookie_reports_cookie_loc() {
2346        let schema = json!({
2347            "type": "object",
2348            "properties": {
2349                "session": {
2350                    "type": "string",
2351                    "source": "cookie"
2352                }
2353            },
2354            "required": ["session"]
2355        });
2356
2357        let validator = ParameterValidator::new(schema).unwrap();
2358
2359        let result = validator.validate_and_extract(
2360            &json!({}),
2361            &HashMap::new(),
2362            &HashMap::new(),
2363            &HashMap::new(),
2364            &HashMap::new(),
2365        );
2366
2367        assert!(result.is_err(), "expected missing cookie to fail");
2368        let err = result.unwrap_err();
2369        assert_eq!(err.errors.len(), 1);
2370        assert_eq!(err.errors[0].error_type, "missing");
2371        assert_eq!(err.errors[0].loc, vec!["cookie".to_string(), "session".to_string()]);
2372    }
2373
2374    #[test]
2375    fn test_query_boolean_empty_string_coerces_to_false() {
2376        let schema = json!({
2377            "type": "object",
2378            "properties": {
2379                "flag": {
2380                    "type": "boolean",
2381                    "source": "query"
2382                }
2383            },
2384            "required": ["flag"]
2385        });
2386
2387        let validator = ParameterValidator::new(schema).unwrap();
2388        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2389        raw_query_params.insert("flag".to_string(), vec!["".to_string()]);
2390
2391        let result = validator.validate_and_extract(
2392            &json!({"flag": ""}),
2393            &raw_query_params,
2394            &HashMap::new(),
2395            &HashMap::new(),
2396            &HashMap::new(),
2397        );
2398
2399        assert!(result.is_ok(), "expected empty string to coerce");
2400        let extracted = result.unwrap();
2401        assert_eq!(extracted["flag"], json!(false));
2402    }
2403
2404    #[test]
2405    fn test_query_array_wraps_scalar_value_and_coerces_items() {
2406        let schema = json!({
2407            "type": "object",
2408            "properties": {
2409                "ids": {
2410                    "type": "array",
2411                    "items": {"type": "integer"},
2412                    "source": "query"
2413                }
2414            },
2415            "required": ["ids"]
2416        });
2417
2418        let validator = ParameterValidator::new(schema).unwrap();
2419
2420        let result = validator.validate_and_extract(
2421            &json!({"ids": "1"}),
2422            &HashMap::new(),
2423            &HashMap::new(),
2424            &HashMap::new(),
2425            &HashMap::new(),
2426        );
2427
2428        assert!(result.is_ok(), "expected scalar query value to coerce into array");
2429        let extracted = result.unwrap();
2430        assert_eq!(extracted["ids"], json!([1]));
2431    }
2432
2433    #[test]
2434    fn test_query_array_invalid_item_returns_parsing_error() {
2435        let schema = json!({
2436            "type": "object",
2437            "properties": {
2438                "ids": {
2439                    "type": "array",
2440                    "items": {"type": "integer"},
2441                    "source": "query"
2442                }
2443            },
2444            "required": ["ids"]
2445        });
2446
2447        let validator = ParameterValidator::new(schema).unwrap();
2448
2449        let result = validator.validate_and_extract(
2450            &json!({"ids": ["x"]}),
2451            &HashMap::new(),
2452            &HashMap::new(),
2453            &HashMap::new(),
2454            &HashMap::new(),
2455        );
2456
2457        assert!(result.is_err(), "expected invalid array item to fail");
2458        let err = result.unwrap_err();
2459        assert_eq!(err.errors.len(), 1);
2460        assert_eq!(err.errors[0].error_type, "int_parsing");
2461        assert_eq!(err.errors[0].loc, vec!["query".to_string(), "ids".to_string()]);
2462    }
2463
2464    #[test]
2465    fn test_uuid_date_datetime_time_and_duration_formats() {
2466        let schema = json!({
2467            "type": "object",
2468            "properties": {
2469                "id": {
2470                    "type": "string",
2471                    "format": "uuid",
2472                    "source": "path"
2473                },
2474                "date": {
2475                    "type": "string",
2476                    "format": "date",
2477                    "source": "query"
2478                },
2479                "dt": {
2480                    "type": "string",
2481                    "format": "date-time",
2482                    "source": "query"
2483                },
2484                "time": {
2485                    "type": "string",
2486                    "format": "time",
2487                    "source": "query"
2488                },
2489                "duration": {
2490                    "type": "string",
2491                    "format": "duration",
2492                    "source": "query"
2493                }
2494            },
2495            "required": ["id", "date", "dt", "time", "duration"]
2496        });
2497
2498        let validator = ParameterValidator::new(schema).unwrap();
2499
2500        let mut path_params = HashMap::new();
2501        path_params.insert("id".to_string(), "550e8400-e29b-41d4-a716-446655440000".to_string());
2502
2503        let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
2504        raw_query_params.insert("date".to_string(), vec!["2025-01-02".to_string()]);
2505        raw_query_params.insert("dt".to_string(), vec!["2025-01-02T03:04:05Z".to_string()]);
2506        raw_query_params.insert("time".to_string(), vec!["03:04:05Z".to_string()]);
2507        raw_query_params.insert("duration".to_string(), vec!["PT1S".to_string()]);
2508
2509        let result = validator.validate_and_extract(
2510            &json!({
2511                "date": "2025-01-02",
2512                "dt": "2025-01-02T03:04:05Z",
2513                "time": "03:04:05Z",
2514                "duration": "PT1S"
2515            }),
2516            &raw_query_params,
2517            &path_params,
2518            &HashMap::new(),
2519            &HashMap::new(),
2520        );
2521        assert!(result.is_ok(), "expected all format values to validate: {result:?}");
2522    }
2523
2524    #[test]
2525    fn test_optional_fields_are_not_required_in_validation_schema() {
2526        let schema = json!({
2527            "type": "object",
2528            "properties": {
2529                "maybe": {
2530                    "type": "string",
2531                    "source": "query",
2532                    "optional": true
2533                }
2534            },
2535            "required": ["maybe"]
2536        });
2537
2538        let validator = ParameterValidator::new(schema).unwrap();
2539        let result = validator.validate_and_extract(
2540            &json!({}),
2541            &HashMap::new(),
2542            &HashMap::new(),
2543            &HashMap::new(),
2544            &HashMap::new(),
2545        );
2546
2547        assert!(result.is_ok(), "optional field in required list should not fail");
2548        let extracted = result.unwrap();
2549        assert_eq!(extracted, json!({}));
2550    }
2551}