spikard 0.8.2 → 0.10.1
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.
- checksums.yaml +4 -4
- data/README.md +19 -10
- data/ext/spikard_rb/Cargo.lock +234 -162
- data/ext/spikard_rb/Cargo.toml +3 -3
- data/ext/spikard_rb/extconf.rb +4 -3
- data/lib/spikard/config.rb +88 -12
- data/lib/spikard/testing.rb +3 -1
- data/lib/spikard/version.rb +1 -1
- data/lib/spikard.rb +11 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +11 -6
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +8 -8
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +63 -25
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +20 -4
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +10 -4
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +25 -22
- data/vendor/crates/spikard-bindings-shared/src/grpc_metadata.rs +14 -12
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +24 -10
- data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +829 -0
- data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +587 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +7 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +17 -11
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +51 -73
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +442 -4
- data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +944 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +22 -10
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +28 -10
- data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +3 -2
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +13 -13
- data/vendor/crates/spikard-bindings-shared/tests/{comprehensive_coverage.rs → full_coverage.rs} +10 -5
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +14 -14
- data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +669 -0
- data/vendor/crates/spikard-core/Cargo.toml +11 -3
- data/vendor/crates/spikard-core/src/bindings/response.rs +6 -9
- data/vendor/crates/spikard-core/src/debug.rs +2 -2
- data/vendor/crates/spikard-core/src/di/container.rs +2 -2
- data/vendor/crates/spikard-core/src/di/error.rs +1 -1
- data/vendor/crates/spikard-core/src/di/factory.rs +9 -5
- data/vendor/crates/spikard-core/src/di/graph.rs +1 -0
- data/vendor/crates/spikard-core/src/di/resolved.rs +25 -2
- data/vendor/crates/spikard-core/src/di/value.rs +2 -1
- data/vendor/crates/spikard-core/src/errors.rs +3 -0
- data/vendor/crates/spikard-core/src/http.rs +94 -18
- data/vendor/crates/spikard-core/src/lifecycle.rs +85 -61
- data/vendor/crates/spikard-core/src/parameters.rs +75 -54
- data/vendor/crates/spikard-core/src/problem.rs +19 -5
- data/vendor/crates/spikard-core/src/request_data.rs +16 -24
- data/vendor/crates/spikard-core/src/router.rs +26 -6
- data/vendor/crates/spikard-core/src/schema_registry.rs +25 -11
- data/vendor/crates/spikard-core/src/type_hints.rs +14 -7
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +30 -16
- data/vendor/crates/spikard-core/src/validation/mod.rs +46 -33
- data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +1 -1
- data/vendor/crates/spikard-core/tests/error_mapper.rs +2 -2
- data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +1 -1
- data/vendor/crates/spikard-core/tests/parameters_full.rs +1 -1
- data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +1 -1
- data/vendor/crates/spikard-core/tests/validation_coverage.rs +4 -4
- data/vendor/crates/spikard-http/Cargo.toml +11 -2
- data/vendor/crates/spikard-http/src/cors.rs +32 -11
- data/vendor/crates/spikard-http/src/di_handler.rs +12 -8
- data/vendor/crates/spikard-http/src/grpc/framing.rs +469 -0
- data/vendor/crates/spikard-http/src/grpc/handler.rs +887 -25
- data/vendor/crates/spikard-http/src/grpc/mod.rs +114 -22
- data/vendor/crates/spikard-http/src/grpc/service.rs +232 -2
- data/vendor/crates/spikard-http/src/grpc/streaming.rs +80 -2
- data/vendor/crates/spikard-http/src/handler_trait.rs +204 -27
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +15 -15
- data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +2 -2
- data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2 -2
- data/vendor/crates/spikard-http/src/lib.rs +1 -1
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +2 -2
- data/vendor/crates/spikard-http/src/lifecycle.rs +4 -4
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +2 -0
- data/vendor/crates/spikard-http/src/server/fast_router.rs +186 -0
- data/vendor/crates/spikard-http/src/server/grpc_routing.rs +324 -23
- data/vendor/crates/spikard-http/src/server/handler.rs +33 -22
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +21 -2
- data/vendor/crates/spikard-http/src/server/mod.rs +125 -20
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +126 -44
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +80 -69
- data/vendor/crates/spikard-http/tests/common/handlers.rs +2 -2
- data/vendor/crates/spikard-http/tests/common/test_builders.rs +12 -12
- data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +2 -2
- data/vendor/crates/spikard-http/tests/di_integration.rs +6 -6
- data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +430 -0
- data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +738 -0
- data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +13 -9
- data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +974 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +2 -2
- data/vendor/crates/spikard-http/tests/request_extraction_full.rs +4 -4
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +2 -2
- data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -0
- data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +140 -0
- data/vendor/crates/spikard-rb/Cargo.toml +11 -1
- data/vendor/crates/spikard-rb/build.rs +1 -0
- data/vendor/crates/spikard-rb/src/conversion.rs +138 -4
- data/vendor/crates/spikard-rb/src/grpc/handler.rs +706 -229
- data/vendor/crates/spikard-rb/src/grpc/mod.rs +6 -2
- data/vendor/crates/spikard-rb/src/gvl.rs +2 -2
- data/vendor/crates/spikard-rb/src/handler.rs +169 -91
- data/vendor/crates/spikard-rb/src/lib.rs +502 -62
- data/vendor/crates/spikard-rb/src/lifecycle.rs +31 -3
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +108 -43
- data/vendor/crates/spikard-rb/src/request.rs +117 -20
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +52 -25
- data/vendor/crates/spikard-rb/src/server.rs +23 -14
- data/vendor/crates/spikard-rb/src/testing/client.rs +5 -4
- data/vendor/crates/spikard-rb/src/testing/sse.rs +1 -36
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +3 -38
- data/vendor/crates/spikard-rb/src/websocket.rs +32 -23
- data/vendor/crates/spikard-rb-macros/Cargo.toml +9 -1
- data/vendor/crates/spikard-rb-macros/src/lib.rs +4 -5
- metadata +14 -4
- data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +0 -5
- data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +0 -2
|
@@ -69,6 +69,9 @@ impl ParameterValidator {
|
|
|
69
69
|
///
|
|
70
70
|
/// The schema should describe all parameters with their types and constraints.
|
|
71
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.
|
|
72
75
|
pub fn new(schema: Value) -> Result<Self, String> {
|
|
73
76
|
let parameter_defs = Self::extract_parameter_defs(&schema)?;
|
|
74
77
|
let validation_schema = Self::create_validation_schema(&schema);
|
|
@@ -88,6 +91,7 @@ impl ParameterValidator {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/// Whether this validator needs access to request headers.
|
|
94
|
+
#[must_use]
|
|
91
95
|
pub fn requires_headers(&self) -> bool {
|
|
92
96
|
self.inner
|
|
93
97
|
.parameter_defs
|
|
@@ -96,6 +100,7 @@ impl ParameterValidator {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
/// Whether this validator needs access to request cookies.
|
|
103
|
+
#[must_use]
|
|
99
104
|
pub fn requires_cookies(&self) -> bool {
|
|
100
105
|
self.inner
|
|
101
106
|
.parameter_defs
|
|
@@ -104,6 +109,7 @@ impl ParameterValidator {
|
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
/// Whether the validator has any parameter definitions.
|
|
112
|
+
#[must_use]
|
|
107
113
|
pub fn has_params(&self) -> bool {
|
|
108
114
|
!self.inner.parameter_defs.is_empty()
|
|
109
115
|
}
|
|
@@ -125,12 +131,22 @@ impl ParameterValidator {
|
|
|
125
131
|
|
|
126
132
|
for (key, child) in obj {
|
|
127
133
|
match key.as_str() {
|
|
128
|
-
// Structural keywords we support in the coercion pass.
|
|
129
|
-
"type"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
| "
|
|
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" => {}
|
|
134
150
|
|
|
135
151
|
// Anything else may impose constraints we don't enforce manually.
|
|
136
152
|
_ => return true,
|
|
@@ -166,15 +182,14 @@ impl ParameterValidator {
|
|
|
166
182
|
for (name, prop) in properties {
|
|
167
183
|
let source_str = prop.get("source").and_then(|s| s.as_str()).ok_or_else(|| {
|
|
168
184
|
anyhow::anyhow!("Invalid parameter schema")
|
|
169
|
-
.context(format!("Parameter '{}' missing required 'source' field"
|
|
185
|
+
.context(format!("Parameter '{name}' missing required 'source' field"))
|
|
170
186
|
.to_string()
|
|
171
187
|
})?;
|
|
172
188
|
|
|
173
189
|
let source = ParameterSource::from_str(source_str).ok_or_else(|| {
|
|
174
190
|
anyhow::anyhow!("Invalid parameter schema")
|
|
175
191
|
.context(format!(
|
|
176
|
-
"Invalid source '{}' for parameter '{}' (expected: query, path, header, or cookie)"
|
|
177
|
-
source_str, name
|
|
192
|
+
"Invalid source '{source_str}' for parameter '{name}' (expected: query, path, header, or cookie)"
|
|
178
193
|
))
|
|
179
194
|
.to_string()
|
|
180
195
|
})?;
|
|
@@ -182,7 +197,10 @@ impl ParameterValidator {
|
|
|
182
197
|
let expected_type = prop.get("type").and_then(|t| t.as_str()).map(String::from);
|
|
183
198
|
let format = prop.get("format").and_then(|f| f.as_str()).map(String::from);
|
|
184
199
|
|
|
185
|
-
let is_optional = prop
|
|
200
|
+
let is_optional = prop
|
|
201
|
+
.get("optional")
|
|
202
|
+
.and_then(serde_json::Value::as_bool)
|
|
203
|
+
.unwrap_or(false);
|
|
186
204
|
let required = required_list.contains(&name.as_str()) && !is_optional;
|
|
187
205
|
|
|
188
206
|
let (lookup_key, error_key) = if source == ParameterSource::Header {
|
|
@@ -207,6 +225,7 @@ impl ParameterValidator {
|
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
/// Get the underlying JSON Schema
|
|
228
|
+
#[must_use]
|
|
210
229
|
pub fn schema(&self) -> &Value {
|
|
211
230
|
&self.inner.schema
|
|
212
231
|
}
|
|
@@ -217,6 +236,10 @@ impl ParameterValidator {
|
|
|
217
236
|
/// It performs type coercion (e.g., "123" → 123) based on the schema.
|
|
218
237
|
///
|
|
219
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)]
|
|
220
243
|
pub fn validate_and_extract(
|
|
221
244
|
&self,
|
|
222
245
|
query_params: &Value,
|
|
@@ -279,11 +302,11 @@ impl ParameterValidator {
|
|
|
279
302
|
"Input should be a valid boolean, unable to interpret input".to_string()
|
|
280
303
|
}
|
|
281
304
|
Some("string") => match item_format {
|
|
282
|
-
Some("uuid") => format!("Input should be a valid UUID, {}"
|
|
283
|
-
Some("date") => format!("Input should be a valid date, {}"
|
|
284
|
-
Some("date-time") => format!("Input should be a valid datetime, {}"
|
|
285
|
-
Some("time") => format!("Input should be a valid time, {}"
|
|
286
|
-
Some("duration") => format!("Input should be a valid duration, {}"
|
|
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}"),
|
|
287
310
|
_ => e,
|
|
288
311
|
},
|
|
289
312
|
_ => e,
|
|
@@ -303,6 +326,7 @@ impl ParameterValidator {
|
|
|
303
326
|
};
|
|
304
327
|
let (item_type, item_format) = self.array_item_type_and_format(¶m_def.name);
|
|
305
328
|
|
|
329
|
+
#[allow(clippy::option_if_let_else)]
|
|
306
330
|
let coerced_items = match array_value.as_array() {
|
|
307
331
|
Some(items) => {
|
|
308
332
|
let mut out = Vec::with_capacity(items.len());
|
|
@@ -332,11 +356,11 @@ impl ParameterValidator {
|
|
|
332
356
|
Some("number") => "Input should be a valid number, unable to parse string as a number".to_string(),
|
|
333
357
|
Some("boolean") => "Input should be a valid boolean, unable to interpret input".to_string(),
|
|
334
358
|
Some("string") => match item_format {
|
|
335
|
-
Some("uuid") => format!("Input should be a valid UUID, {}"
|
|
336
|
-
Some("date") => format!("Input should be a valid date, {}"
|
|
337
|
-
Some("date-time") => format!("Input should be a valid datetime, {}"
|
|
338
|
-
Some("time") => format!("Input should be a valid time, {}"
|
|
339
|
-
Some("duration") => format!("Input should be a valid duration, {}"
|
|
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}"),
|
|
340
364
|
_ => e.clone(),
|
|
341
365
|
},
|
|
342
366
|
_ => e.clone(),
|
|
@@ -410,19 +434,19 @@ impl ParameterValidator {
|
|
|
410
434
|
"Input should be a valid boolean, unable to interpret input".to_string(),
|
|
411
435
|
),
|
|
412
436
|
(Some("string"), Some("uuid")) => {
|
|
413
|
-
("uuid_parsing", format!("Input should be a valid UUID, {}"
|
|
437
|
+
("uuid_parsing", format!("Input should be a valid UUID, {e}"))
|
|
414
438
|
}
|
|
415
439
|
(Some("string"), Some("date")) => {
|
|
416
|
-
("date_parsing", format!("Input should be a valid date, {}"
|
|
440
|
+
("date_parsing", format!("Input should be a valid date, {e}"))
|
|
417
441
|
}
|
|
418
442
|
(Some("string"), Some("date-time")) => {
|
|
419
|
-
("datetime_parsing", format!("Input should be a valid datetime, {}"
|
|
443
|
+
("datetime_parsing", format!("Input should be a valid datetime, {e}"))
|
|
420
444
|
}
|
|
421
445
|
(Some("string"), Some("time")) => {
|
|
422
|
-
("time_parsing", format!("Input should be a valid time, {}"
|
|
446
|
+
("time_parsing", format!("Input should be a valid time, {e}"))
|
|
423
447
|
}
|
|
424
448
|
(Some("string"), Some("duration")) => {
|
|
425
|
-
("duration_parsing", format!("Input should be a valid duration, {}"
|
|
449
|
+
("duration_parsing", format!("Input should be a valid duration, {e}"))
|
|
426
450
|
}
|
|
427
451
|
_ => ("type_error", e),
|
|
428
452
|
};
|
|
@@ -445,7 +469,7 @@ impl ParameterValidator {
|
|
|
445
469
|
let params_json = Value::Object(params_map);
|
|
446
470
|
if let Some(schema_validator) = &self.inner.schema_validator {
|
|
447
471
|
match schema_validator.validate(¶ms_json) {
|
|
448
|
-
Ok(
|
|
472
|
+
Ok(()) => Ok(params_json),
|
|
449
473
|
Err(mut validation_err) => {
|
|
450
474
|
for error in &mut validation_err.errors {
|
|
451
475
|
if error.loc.len() >= 2 && error.loc[0] == "body" {
|
|
@@ -459,7 +483,7 @@ impl ParameterValidator {
|
|
|
459
483
|
};
|
|
460
484
|
error.loc[0] = source_str.to_string();
|
|
461
485
|
if param_def.source == ParameterSource::Header {
|
|
462
|
-
error.loc[1]
|
|
486
|
+
error.loc[1].clone_from(¶m_def.error_key);
|
|
463
487
|
}
|
|
464
488
|
if let Some(raw_value) =
|
|
465
489
|
self.raw_value_for_error(param_def, raw_query_params, path_params, headers, cookies)
|
|
@@ -477,6 +501,7 @@ impl ParameterValidator {
|
|
|
477
501
|
}
|
|
478
502
|
}
|
|
479
503
|
|
|
504
|
+
#[allow(clippy::unused_self)]
|
|
480
505
|
fn raw_value_for_error<'a>(
|
|
481
506
|
&self,
|
|
482
507
|
param_def: &ParameterDef,
|
|
@@ -485,6 +510,7 @@ impl ParameterValidator {
|
|
|
485
510
|
headers: &'a HashMap<String, String>,
|
|
486
511
|
cookies: &'a HashMap<String, String>,
|
|
487
512
|
) -> Option<&'a str> {
|
|
513
|
+
#[allow(clippy::too_many_arguments)]
|
|
488
514
|
match param_def.source {
|
|
489
515
|
ParameterSource::Query => raw_query_params
|
|
490
516
|
.get(¶m_def.lookup_key)
|
|
@@ -548,11 +574,11 @@ impl ParameterValidator {
|
|
|
548
574
|
Some("integer") => value
|
|
549
575
|
.parse::<i64>()
|
|
550
576
|
.map(|i| json!(i))
|
|
551
|
-
.map_err(|e| format!("Invalid integer: {}"
|
|
577
|
+
.map_err(|e| format!("Invalid integer: {e}")),
|
|
552
578
|
Some("number") => value
|
|
553
579
|
.parse::<f64>()
|
|
554
580
|
.map(|f| json!(f))
|
|
555
|
-
.map_err(|e| format!("Invalid number: {}"
|
|
581
|
+
.map_err(|e| format!("Invalid number: {e}")),
|
|
556
582
|
Some("boolean") => {
|
|
557
583
|
if value.is_empty() {
|
|
558
584
|
return Ok(json!(false));
|
|
@@ -563,7 +589,7 @@ impl ParameterValidator {
|
|
|
563
589
|
} else if value_lower == "false" || value == "0" {
|
|
564
590
|
Ok(json!(false))
|
|
565
591
|
} else {
|
|
566
|
-
Err(format!("Invalid boolean: {}"
|
|
592
|
+
Err(format!("Invalid boolean: {value}"))
|
|
567
593
|
}
|
|
568
594
|
}
|
|
569
595
|
_ => Ok(json!(value)),
|
|
@@ -574,7 +600,7 @@ impl ParameterValidator {
|
|
|
574
600
|
fn validate_date_format(value: &str) -> Result<(), String> {
|
|
575
601
|
jiff::civil::Date::strptime("%Y-%m-%d", value)
|
|
576
602
|
.map(|_| ())
|
|
577
|
-
.map_err(|e| format!("Invalid date format: {}"
|
|
603
|
+
.map_err(|e| format!("Invalid date format: {e}"))
|
|
578
604
|
}
|
|
579
605
|
|
|
580
606
|
/// Validate ISO 8601 datetime format
|
|
@@ -582,7 +608,7 @@ impl ParameterValidator {
|
|
|
582
608
|
use std::str::FromStr;
|
|
583
609
|
jiff::Timestamp::from_str(value)
|
|
584
610
|
.map(|_| ())
|
|
585
|
-
.map_err(|e| format!("Invalid datetime format: {}"
|
|
611
|
+
.map_err(|e| format!("Invalid datetime format: {e}"))
|
|
586
612
|
}
|
|
587
613
|
|
|
588
614
|
/// Validate ISO 8601 time format: HH:MM:SS or HH:MM:SS.ffffff
|
|
@@ -608,7 +634,7 @@ impl ParameterValidator {
|
|
|
608
634
|
};
|
|
609
635
|
|
|
610
636
|
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: {}"
|
|
637
|
+
jiff::civil::Time::strptime("%H:%M:%S", base_time).map_err(|e| format!("Invalid time format: {e}"))?;
|
|
612
638
|
|
|
613
639
|
if let Some((_, frac)) = time_part.split_once('.')
|
|
614
640
|
&& (frac.is_empty() || frac.len() > 9 || !frac.chars().all(|c| c.is_ascii_digit()))
|
|
@@ -648,7 +674,7 @@ impl ParameterValidator {
|
|
|
648
674
|
use std::str::FromStr;
|
|
649
675
|
jiff::Span::from_str(value)
|
|
650
676
|
.map(|_| ())
|
|
651
|
-
.map_err(|e| format!("Invalid duration format: {}"
|
|
677
|
+
.map_err(|e| format!("Invalid duration format: {e}"))
|
|
652
678
|
}
|
|
653
679
|
|
|
654
680
|
/// Validate UUID format
|
|
@@ -671,7 +697,7 @@ impl ParameterValidator {
|
|
|
671
697
|
for (name, prop) in properties.iter_mut() {
|
|
672
698
|
if let Some(obj) = prop.as_object_mut() {
|
|
673
699
|
obj.remove("source");
|
|
674
|
-
if obj.get("optional").and_then(
|
|
700
|
+
if obj.get("optional").and_then(serde_json::Value::as_bool) == Some(true) {
|
|
675
701
|
optional_fields.push(name.clone());
|
|
676
702
|
}
|
|
677
703
|
obj.remove("optional");
|
|
@@ -798,7 +824,7 @@ mod tests {
|
|
|
798
824
|
&HashMap::new(),
|
|
799
825
|
&HashMap::new(),
|
|
800
826
|
);
|
|
801
|
-
assert!(result.is_ok(), "Validation should succeed: {:?}"
|
|
827
|
+
assert!(result.is_ok(), "Validation should succeed: {result:?}");
|
|
802
828
|
|
|
803
829
|
let params = result.unwrap();
|
|
804
830
|
assert_eq!(params, json!({"item_id": "foobar"}));
|
|
@@ -832,9 +858,9 @@ mod tests {
|
|
|
832
858
|
&HashMap::new(),
|
|
833
859
|
);
|
|
834
860
|
if result.is_err() {
|
|
835
|
-
eprintln!("Error for 'True': {:?}"
|
|
861
|
+
eprintln!("Error for 'True': {result:?}");
|
|
836
862
|
}
|
|
837
|
-
assert!(result.is_ok(), "Validation should succeed for 'True': {:?}"
|
|
863
|
+
assert!(result.is_ok(), "Validation should succeed for 'True': {result:?}");
|
|
838
864
|
let params = result.unwrap();
|
|
839
865
|
assert_eq!(params, json!({"value": true}));
|
|
840
866
|
|
|
@@ -847,7 +873,7 @@ mod tests {
|
|
|
847
873
|
&HashMap::new(),
|
|
848
874
|
&HashMap::new(),
|
|
849
875
|
);
|
|
850
|
-
assert!(result.is_ok(), "Validation should succeed for '1': {:?}"
|
|
876
|
+
assert!(result.is_ok(), "Validation should succeed for '1': {result:?}");
|
|
851
877
|
let params = result.unwrap();
|
|
852
878
|
assert_eq!(params, json!({"value": true}));
|
|
853
879
|
|
|
@@ -860,7 +886,7 @@ mod tests {
|
|
|
860
886
|
&HashMap::new(),
|
|
861
887
|
&HashMap::new(),
|
|
862
888
|
);
|
|
863
|
-
assert!(result.is_ok(), "Validation should succeed for 'false': {:?}"
|
|
889
|
+
assert!(result.is_ok(), "Validation should succeed for 'false': {result:?}");
|
|
864
890
|
let params = result.unwrap();
|
|
865
891
|
assert_eq!(params, json!({"value": false}));
|
|
866
892
|
|
|
@@ -873,7 +899,7 @@ mod tests {
|
|
|
873
899
|
&HashMap::new(),
|
|
874
900
|
&HashMap::new(),
|
|
875
901
|
);
|
|
876
|
-
assert!(result.is_ok(), "Validation should succeed for 'TRUE': {:?}"
|
|
902
|
+
assert!(result.is_ok(), "Validation should succeed for 'TRUE': {result:?}");
|
|
877
903
|
let params = result.unwrap();
|
|
878
904
|
assert_eq!(params, json!({"value": true}));
|
|
879
905
|
}
|
|
@@ -904,7 +930,7 @@ mod tests {
|
|
|
904
930
|
&HashMap::new(),
|
|
905
931
|
&HashMap::new(),
|
|
906
932
|
);
|
|
907
|
-
assert!(result.is_ok(), "Validation should succeed for integer 1: {:?}"
|
|
933
|
+
assert!(result.is_ok(), "Validation should succeed for integer 1: {result:?}");
|
|
908
934
|
let params = result.unwrap();
|
|
909
935
|
assert_eq!(params, json!({"flag": true}));
|
|
910
936
|
|
|
@@ -918,7 +944,7 @@ mod tests {
|
|
|
918
944
|
&HashMap::new(),
|
|
919
945
|
&HashMap::new(),
|
|
920
946
|
);
|
|
921
|
-
assert!(result.is_ok(), "Validation should succeed for integer 0: {:?}"
|
|
947
|
+
assert!(result.is_ok(), "Validation should succeed for integer 0: {result:?}");
|
|
922
948
|
let params = result.unwrap();
|
|
923
949
|
assert_eq!(params, json!({"flag": false}));
|
|
924
950
|
|
|
@@ -932,11 +958,7 @@ mod tests {
|
|
|
932
958
|
&HashMap::new(),
|
|
933
959
|
&HashMap::new(),
|
|
934
960
|
);
|
|
935
|
-
assert!(
|
|
936
|
-
result.is_ok(),
|
|
937
|
-
"Validation should succeed for boolean true: {:?}",
|
|
938
|
-
result
|
|
939
|
-
);
|
|
961
|
+
assert!(result.is_ok(), "Validation should succeed for boolean true: {result:?}");
|
|
940
962
|
let params = result.unwrap();
|
|
941
963
|
assert_eq!(params, json!({"flag": true}));
|
|
942
964
|
|
|
@@ -952,8 +974,7 @@ mod tests {
|
|
|
952
974
|
);
|
|
953
975
|
assert!(
|
|
954
976
|
result.is_ok(),
|
|
955
|
-
"Validation should succeed for boolean false: {:?}"
|
|
956
|
-
result
|
|
977
|
+
"Validation should succeed for boolean false: {result:?}"
|
|
957
978
|
);
|
|
958
979
|
let params = result.unwrap();
|
|
959
980
|
assert_eq!(params, json!({"flag": false}));
|
|
@@ -1165,7 +1186,7 @@ mod tests {
|
|
|
1165
1186
|
|
|
1166
1187
|
let validator = ParameterValidator::new(schema).unwrap();
|
|
1167
1188
|
let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
|
|
1168
|
-
raw_query_params.insert("flag".to_string(), vec![
|
|
1189
|
+
raw_query_params.insert("flag".to_string(), vec![String::new()]);
|
|
1169
1190
|
|
|
1170
1191
|
let result = validator
|
|
1171
1192
|
.validate_and_extract(
|
|
@@ -1323,7 +1344,7 @@ mod tests {
|
|
|
1323
1344
|
|
|
1324
1345
|
let validator = ParameterValidator::new(schema).unwrap();
|
|
1325
1346
|
let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
|
|
1326
|
-
raw_query_params.insert("flag".to_string(), vec![
|
|
1347
|
+
raw_query_params.insert("flag".to_string(), vec![String::new()]);
|
|
1327
1348
|
|
|
1328
1349
|
let result = validator.validate_and_extract(
|
|
1329
1350
|
&json!({"flag": ""}),
|
|
@@ -1767,7 +1788,7 @@ mod tests {
|
|
|
1767
1788
|
&HashMap::new(),
|
|
1768
1789
|
);
|
|
1769
1790
|
|
|
1770
|
-
assert!(result.is_ok(), "String parameter should pass: {:?}"
|
|
1791
|
+
assert!(result.is_ok(), "String parameter should pass: {result:?}");
|
|
1771
1792
|
let extracted = result.unwrap();
|
|
1772
1793
|
assert_eq!(extracted["start_time"], json!(time_string));
|
|
1773
1794
|
}
|
|
@@ -2360,7 +2381,7 @@ mod tests {
|
|
|
2360
2381
|
|
|
2361
2382
|
let validator = ParameterValidator::new(schema).unwrap();
|
|
2362
2383
|
let mut raw_query_params: HashMap<String, Vec<String>> = HashMap::new();
|
|
2363
|
-
raw_query_params.insert("flag".to_string(), vec![
|
|
2384
|
+
raw_query_params.insert("flag".to_string(), vec![String::new()]);
|
|
2364
2385
|
|
|
2365
2386
|
let result = validator.validate_and_extract(
|
|
2366
2387
|
&json!({"flag": ""}),
|
|
@@ -82,7 +82,8 @@ impl ProblemDetails {
|
|
|
82
82
|
/// Standard type URI for bad request (400)
|
|
83
83
|
pub const TYPE_BAD_REQUEST: &'static str = "https://spikard.dev/errors/bad-request";
|
|
84
84
|
|
|
85
|
-
/// Create a new ProblemDetails with required fields
|
|
85
|
+
/// Create a new `ProblemDetails` with required fields
|
|
86
|
+
#[must_use]
|
|
86
87
|
pub fn new(type_uri: impl Into<String>, title: impl Into<String>, status: StatusCode) -> Self {
|
|
87
88
|
Self {
|
|
88
89
|
type_uri: type_uri.into(),
|
|
@@ -95,24 +96,29 @@ impl ProblemDetails {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/// Set the detail field
|
|
99
|
+
#[must_use]
|
|
98
100
|
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
|
|
99
101
|
self.detail = Some(detail.into());
|
|
100
102
|
self
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/// Set the instance field
|
|
106
|
+
#[must_use]
|
|
104
107
|
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
|
|
105
108
|
self.instance = Some(instance.into());
|
|
106
109
|
self
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/// Add an extension field
|
|
113
|
+
#[must_use]
|
|
110
114
|
pub fn with_extension(mut self, key: impl Into<String>, value: Value) -> Self {
|
|
111
115
|
self.extensions.insert(key.into(), value);
|
|
112
116
|
self
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/// Add all extensions from a JSON object
|
|
120
|
+
#[must_use]
|
|
121
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
116
122
|
pub fn with_extensions(mut self, extensions: Value) -> Self {
|
|
117
123
|
if let Some(obj) = extensions.as_object() {
|
|
118
124
|
for (key, value) in obj {
|
|
@@ -122,20 +128,21 @@ impl ProblemDetails {
|
|
|
122
128
|
self
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
/// Create a validation error Problem Details from ValidationError
|
|
131
|
+
/// Create a validation error Problem Details from `ValidationError`
|
|
126
132
|
///
|
|
127
133
|
/// This converts the FastAPI-style validation errors to RFC 9457 format:
|
|
128
|
-
/// - `type`:
|
|
134
|
+
/// - `type`: <https://spikard.dev/errors/validation-error>
|
|
129
135
|
/// - `title`: "Request Validation Failed"
|
|
130
136
|
/// - `status`: 422
|
|
131
137
|
/// - `detail`: Summary of error count
|
|
132
138
|
/// - `errors`: Array of validation error details (as extension field)
|
|
139
|
+
#[must_use]
|
|
133
140
|
pub fn from_validation_error(error: &ValidationError) -> Self {
|
|
134
141
|
let error_count = error.errors.len();
|
|
135
142
|
let detail = if error_count == 1 {
|
|
136
143
|
"1 validation error in request".to_string()
|
|
137
144
|
} else {
|
|
138
|
-
format!("{} validation errors in request"
|
|
145
|
+
format!("{error_count} validation errors in request")
|
|
139
146
|
};
|
|
140
147
|
|
|
141
148
|
let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
|
|
@@ -201,16 +208,23 @@ impl ProblemDetails {
|
|
|
201
208
|
}
|
|
202
209
|
|
|
203
210
|
/// Get the HTTP status code
|
|
211
|
+
#[must_use]
|
|
204
212
|
pub fn status_code(&self) -> StatusCode {
|
|
205
213
|
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
/// Serialize to JSON string
|
|
217
|
+
///
|
|
218
|
+
/// # Errors
|
|
219
|
+
/// Returns an error if the serialization fails.
|
|
209
220
|
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
|
210
221
|
serde_json::to_string(self)
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
/// Serialize to pretty JSON string
|
|
225
|
+
///
|
|
226
|
+
/// # Errors
|
|
227
|
+
/// Returns an error if the serialization fails.
|
|
214
228
|
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
|
|
215
229
|
serde_json::to_string_pretty(self)
|
|
216
230
|
}
|
|
@@ -233,7 +247,7 @@ mod tests {
|
|
|
233
247
|
error_type: "missing".to_string(),
|
|
234
248
|
loc: vec!["body".to_string(), "username".to_string()],
|
|
235
249
|
msg: "Field required".to_string(),
|
|
236
|
-
input: Value::String(
|
|
250
|
+
input: Value::String(String::new()),
|
|
237
251
|
ctx: None,
|
|
238
252
|
},
|
|
239
253
|
ValidationErrorDetail {
|
|
@@ -17,19 +17,19 @@ use bytes::Bytes;
|
|
|
17
17
|
///
|
|
18
18
|
/// This is the language-agnostic representation passed to handlers.
|
|
19
19
|
///
|
|
20
|
-
/// Uses Arc for
|
|
21
|
-
/// When RequestData is cloned, only the Arc pointers are cloned, not the underlying data.
|
|
20
|
+
/// Uses `Arc` for `HashMap`s to enable cheap cloning without duplicating data.
|
|
21
|
+
/// When `RequestData` is cloned, only the `Arc` pointers are cloned, not the underlying data.
|
|
22
22
|
///
|
|
23
|
-
/// Performance optimization: raw_body stores the unparsed request body bytes.
|
|
24
|
-
/// Language bindings should use raw_body when possible to avoid double-parsing.
|
|
25
|
-
/// The body field is lazily parsed only when needed for validation.
|
|
23
|
+
/// Performance optimization: `raw_body` stores the unparsed request body bytes.
|
|
24
|
+
/// Language bindings should use `raw_body` when possible to avoid double-parsing.
|
|
25
|
+
/// The `body` field is lazily parsed only when needed for validation.
|
|
26
26
|
#[derive(Debug, Clone)]
|
|
27
27
|
pub struct RequestData {
|
|
28
28
|
/// Path parameters extracted from the URL path
|
|
29
29
|
pub path_params: Arc<HashMap<String, String>>,
|
|
30
30
|
/// Query parameters parsed as JSON
|
|
31
31
|
pub query_params: Value,
|
|
32
|
-
/// Validated parameters produced by ParameterValidator (query/path/header/cookie combined).
|
|
32
|
+
/// Validated parameters produced by `ParameterValidator` (query/path/header/cookie combined).
|
|
33
33
|
pub validated_params: Option<Value>,
|
|
34
34
|
/// Raw query parameters as key-value pairs
|
|
35
35
|
pub raw_query_params: Arc<HashMap<String, Vec<String>>>,
|
|
@@ -66,7 +66,7 @@ impl Serialize for RequestData {
|
|
|
66
66
|
state.serialize_field("raw_query_params", &*self.raw_query_params)?;
|
|
67
67
|
state.serialize_field("body", &self.body)?;
|
|
68
68
|
#[cfg(feature = "di")]
|
|
69
|
-
state.serialize_field("raw_body", &self.raw_body.as_ref().map(
|
|
69
|
+
state.serialize_field("raw_body", &self.raw_body.as_ref().map(AsRef::as_ref))?;
|
|
70
70
|
#[cfg(not(feature = "di"))]
|
|
71
71
|
state.serialize_field("raw_body", &self.raw_body)?;
|
|
72
72
|
state.serialize_field("headers", &*self.headers)?;
|
|
@@ -78,6 +78,7 @@ impl Serialize for RequestData {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
impl<'de> Deserialize<'de> for RequestData {
|
|
81
|
+
#[allow(clippy::too_many_lines)]
|
|
81
82
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
82
83
|
where
|
|
83
84
|
D: serde::Deserializer<'de>,
|
|
@@ -829,20 +830,11 @@ mod tests {
|
|
|
829
830
|
fn test_request_data_large_json_body() {
|
|
830
831
|
let mut large_obj = serde_json::Map::new();
|
|
831
832
|
for i in 0..100 {
|
|
832
|
-
large_obj.insert(
|
|
833
|
-
format!("field_{}", i),
|
|
834
|
-
json!({"value": i, "name": format!("item_{}", i)}),
|
|
835
|
-
);
|
|
833
|
+
large_obj.insert(format!("field_{i}"), json!({"value": i, "name": format!("item_{}", i)}));
|
|
836
834
|
}
|
|
837
835
|
let body = json!(large_obj);
|
|
838
836
|
|
|
839
|
-
let data = create_request_data(
|
|
840
|
-
"/api/batch",
|
|
841
|
-
"POST",
|
|
842
|
-
body.clone(),
|
|
843
|
-
json!({}),
|
|
844
|
-
RequestDataCollections::default(),
|
|
845
|
-
);
|
|
837
|
+
let data = create_request_data("/api/batch", "POST", body, json!({}), RequestDataCollections::default());
|
|
846
838
|
|
|
847
839
|
assert!(data.body.is_object());
|
|
848
840
|
assert_eq!(data.body.as_object().unwrap().len(), 100);
|
|
@@ -933,7 +925,7 @@ mod tests {
|
|
|
933
925
|
"float": 3.2,
|
|
934
926
|
"negative": -100,
|
|
935
927
|
"zero": 0,
|
|
936
|
-
"large":
|
|
928
|
+
"large": 9_223_372_036_854_775_807i64
|
|
937
929
|
});
|
|
938
930
|
|
|
939
931
|
let data = create_request_data(
|
|
@@ -999,10 +991,10 @@ mod tests {
|
|
|
999
991
|
#[test]
|
|
1000
992
|
fn test_request_data_empty_string_values() {
|
|
1001
993
|
let mut headers = HashMap::new();
|
|
1002
|
-
headers.insert("empty-header".to_string(),
|
|
994
|
+
headers.insert("empty-header".to_string(), String::new());
|
|
1003
995
|
|
|
1004
996
|
let mut path_params = HashMap::new();
|
|
1005
|
-
path_params.insert("id".to_string(),
|
|
997
|
+
path_params.insert("id".to_string(), String::new());
|
|
1006
998
|
|
|
1007
999
|
let data = create_request_data(
|
|
1008
1000
|
"/api/test",
|
|
@@ -1016,8 +1008,8 @@ mod tests {
|
|
|
1016
1008
|
},
|
|
1017
1009
|
);
|
|
1018
1010
|
|
|
1019
|
-
assert_eq!(data.headers.get("empty-header"), Some(&
|
|
1020
|
-
assert_eq!(data.path_params.get("id"), Some(&
|
|
1011
|
+
assert_eq!(data.headers.get("empty-header"), Some(&String::new()));
|
|
1012
|
+
assert_eq!(data.path_params.get("id"), Some(&String::new()));
|
|
1021
1013
|
}
|
|
1022
1014
|
|
|
1023
1015
|
#[test]
|
|
@@ -1091,7 +1083,7 @@ mod tests {
|
|
|
1091
1083
|
RequestDataCollections::default(),
|
|
1092
1084
|
);
|
|
1093
1085
|
|
|
1094
|
-
let debug_str = format!("{:?}"
|
|
1086
|
+
let debug_str = format!("{data:?}");
|
|
1095
1087
|
assert!(debug_str.contains("RequestData"));
|
|
1096
1088
|
assert!(debug_str.contains("/api/test"));
|
|
1097
1089
|
}
|