spikard 0.3.6 → 0.5.0

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