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
|
@@ -70,8 +70,8 @@ pub struct JsonRpcMethodInfo {
|
|
|
70
70
|
|
|
71
71
|
/// Route definition with compiled validators
|
|
72
72
|
///
|
|
73
|
-
/// Validators are Arc
|
|
74
|
-
/// and to support schema deduplication via SchemaRegistry
|
|
73
|
+
/// Validators are `Arc`-wrapped to enable cheap cloning across route instances
|
|
74
|
+
/// and to support schema deduplication via `SchemaRegistry`.
|
|
75
75
|
///
|
|
76
76
|
/// The `jsonrpc_method` field is optional and has zero overhead when None,
|
|
77
77
|
/// enabling routes to optionally expose themselves as JSON-RPC methods.
|
|
@@ -102,10 +102,14 @@ impl Route {
|
|
|
102
102
|
///
|
|
103
103
|
/// Auto-generates parameter schema from type hints in the path if no explicit schema provided.
|
|
104
104
|
/// Type hints like `/items/{id:uuid}` generate appropriate JSON Schema validation.
|
|
105
|
-
/// Explicit parameter_schema overrides auto-generated schemas.
|
|
105
|
+
/// Explicit `parameter_schema` overrides auto-generated schemas.
|
|
106
|
+
///
|
|
107
|
+
/// # Errors
|
|
108
|
+
/// Returns an error if the schema compilation fails or metadata is invalid.
|
|
106
109
|
///
|
|
107
110
|
/// The schema registry ensures each unique schema is compiled only once, improving
|
|
108
111
|
/// startup performance and memory usage for applications with many routes.
|
|
112
|
+
#[allow(clippy::items_after_statements)]
|
|
109
113
|
pub fn from_metadata(metadata: RouteMetadata, registry: &SchemaRegistry) -> Result<Self, String> {
|
|
110
114
|
let method = metadata.method.parse()?;
|
|
111
115
|
|
|
@@ -135,7 +139,10 @@ impl Route {
|
|
|
135
139
|
if is_empty_schema(&explicit_schema) {
|
|
136
140
|
Some(auto_schema)
|
|
137
141
|
} else {
|
|
138
|
-
Some(crate::type_hints::merge_parameter_schemas(
|
|
142
|
+
Some(crate::type_hints::merge_parameter_schemas(
|
|
143
|
+
&auto_schema,
|
|
144
|
+
&explicit_schema,
|
|
145
|
+
))
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
(Some(auto_schema), None) => Some(auto_schema),
|
|
@@ -187,17 +194,20 @@ impl Route {
|
|
|
187
194
|
/// tags: vec!["users".to_string()],
|
|
188
195
|
/// });
|
|
189
196
|
/// ```
|
|
197
|
+
#[must_use]
|
|
190
198
|
pub fn with_jsonrpc_method(mut self, info: JsonRpcMethodInfo) -> Self {
|
|
191
199
|
self.jsonrpc_method = Some(info);
|
|
192
200
|
self
|
|
193
201
|
}
|
|
194
202
|
|
|
195
203
|
/// Check if this route has JSON-RPC metadata
|
|
196
|
-
|
|
204
|
+
#[must_use]
|
|
205
|
+
pub const fn is_jsonrpc_method(&self) -> bool {
|
|
197
206
|
self.jsonrpc_method.is_some()
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
/// Get the JSON-RPC method name if present
|
|
210
|
+
#[must_use]
|
|
201
211
|
pub fn jsonrpc_method_name(&self) -> Option<&str> {
|
|
202
212
|
self.jsonrpc_method.as_ref().map(|m| m.method_name.as_str())
|
|
203
213
|
}
|
|
@@ -210,6 +220,7 @@ pub struct Router {
|
|
|
210
220
|
|
|
211
221
|
impl Router {
|
|
212
222
|
/// Create a new router
|
|
223
|
+
#[must_use]
|
|
213
224
|
pub fn new() -> Self {
|
|
214
225
|
Self { routes: HashMap::new() }
|
|
215
226
|
}
|
|
@@ -221,18 +232,21 @@ impl Router {
|
|
|
221
232
|
}
|
|
222
233
|
|
|
223
234
|
/// Find a route by method and path
|
|
235
|
+
#[must_use]
|
|
224
236
|
pub fn find_route(&self, method: &Method, path: &str) -> Option<&Route> {
|
|
225
237
|
self.routes.get(path)?.get(method)
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
/// Get all routes
|
|
241
|
+
#[must_use]
|
|
229
242
|
pub fn routes(&self) -> Vec<&Route> {
|
|
230
243
|
self.routes.values().flat_map(|methods| methods.values()).collect()
|
|
231
244
|
}
|
|
232
245
|
|
|
233
246
|
/// Get route count
|
|
247
|
+
#[must_use]
|
|
234
248
|
pub fn route_count(&self) -> usize {
|
|
235
|
-
self.routes.values().map(
|
|
249
|
+
self.routes.values().map(std::collections::HashMap::len).sum()
|
|
236
250
|
}
|
|
237
251
|
}
|
|
238
252
|
|
|
@@ -264,6 +278,7 @@ mod tests {
|
|
|
264
278
|
cors: None,
|
|
265
279
|
body_param_name: None,
|
|
266
280
|
jsonrpc_method: None,
|
|
281
|
+
static_response: None,
|
|
267
282
|
#[cfg(feature = "di")]
|
|
268
283
|
handler_dependencies: None,
|
|
269
284
|
};
|
|
@@ -298,6 +313,7 @@ mod tests {
|
|
|
298
313
|
cors: None,
|
|
299
314
|
body_param_name: None,
|
|
300
315
|
jsonrpc_method: None,
|
|
316
|
+
static_response: None,
|
|
301
317
|
#[cfg(feature = "di")]
|
|
302
318
|
handler_dependencies: None,
|
|
303
319
|
};
|
|
@@ -330,6 +346,7 @@ mod tests {
|
|
|
330
346
|
cors: None,
|
|
331
347
|
body_param_name: None,
|
|
332
348
|
jsonrpc_method: None,
|
|
349
|
+
static_response: None,
|
|
333
350
|
#[cfg(feature = "di")]
|
|
334
351
|
handler_dependencies: None,
|
|
335
352
|
};
|
|
@@ -346,6 +363,7 @@ mod tests {
|
|
|
346
363
|
cors: None,
|
|
347
364
|
body_param_name: None,
|
|
348
365
|
jsonrpc_method: None,
|
|
366
|
+
static_response: None,
|
|
349
367
|
#[cfg(feature = "di")]
|
|
350
368
|
handler_dependencies: None,
|
|
351
369
|
};
|
|
@@ -424,6 +442,7 @@ mod tests {
|
|
|
424
442
|
cors: None,
|
|
425
443
|
body_param_name: None,
|
|
426
444
|
jsonrpc_method: None,
|
|
445
|
+
static_response: None,
|
|
427
446
|
#[cfg(feature = "di")]
|
|
428
447
|
handler_dependencies: None,
|
|
429
448
|
};
|
|
@@ -497,6 +516,7 @@ mod tests {
|
|
|
497
516
|
cors: None,
|
|
498
517
|
body_param_name: None,
|
|
499
518
|
jsonrpc_method: None,
|
|
519
|
+
static_response: None,
|
|
500
520
|
#[cfg(feature = "di")]
|
|
501
521
|
handler_dependencies: None,
|
|
502
522
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
//! Schema registry for deduplication and OpenAPI generation
|
|
1
|
+
//! Schema registry for deduplication and `OpenAPI` generation
|
|
2
2
|
//!
|
|
3
3
|
//! This module provides a global registry that compiles JSON schemas once at application
|
|
4
4
|
//! startup and reuses them across all routes. This enables:
|
|
5
5
|
//! - Schema deduplication (same schema used by multiple routes)
|
|
6
|
-
//! - OpenAPI spec generation (access to all schemas)
|
|
6
|
+
//! - `OpenAPI` spec generation (access to all schemas)
|
|
7
7
|
//! - Memory efficiency (one compiled validator per unique schema)
|
|
8
8
|
|
|
9
9
|
use crate::validation::SchemaValidator;
|
|
@@ -14,7 +14,7 @@ use std::sync::{Arc, RwLock};
|
|
|
14
14
|
/// Global schema registry for compiled validators
|
|
15
15
|
///
|
|
16
16
|
/// Thread-safe registry that ensures each unique schema is compiled exactly once.
|
|
17
|
-
/// Uses RwLock for concurrent read access with occasional writes during startup.
|
|
17
|
+
/// Uses `RwLock` for concurrent read access with occasional writes during startup.
|
|
18
18
|
pub struct SchemaRegistry {
|
|
19
19
|
/// Map from schema JSON string to compiled validator
|
|
20
20
|
schemas: RwLock<HashMap<String, Arc<SchemaValidator>>>,
|
|
@@ -22,13 +22,14 @@ pub struct SchemaRegistry {
|
|
|
22
22
|
|
|
23
23
|
impl SchemaRegistry {
|
|
24
24
|
/// Create a new empty schema registry
|
|
25
|
+
#[must_use]
|
|
25
26
|
pub fn new() -> Self {
|
|
26
27
|
Self {
|
|
27
28
|
schemas: RwLock::new(HashMap::new()),
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/// Get or compile a schema, returning Arc to the compiled validator
|
|
32
|
+
/// Get or compile a schema, returning `Arc` to the compiled validator
|
|
32
33
|
///
|
|
33
34
|
/// This method is thread-safe and uses a double-check pattern:
|
|
34
35
|
/// 1. Fast path: Read lock to check if schema exists
|
|
@@ -38,9 +39,15 @@ impl SchemaRegistry {
|
|
|
38
39
|
/// * `schema` - The JSON schema to compile
|
|
39
40
|
///
|
|
40
41
|
/// # Returns
|
|
41
|
-
/// Arc
|
|
42
|
+
/// `Arc`-wrapped compiled validator that can be cheaply cloned
|
|
43
|
+
///
|
|
44
|
+
/// # Errors
|
|
45
|
+
/// Returns an error if schema serialization or compilation fails.
|
|
46
|
+
///
|
|
47
|
+
/// # Panics
|
|
48
|
+
/// Panics if the read or write lock is poisoned.
|
|
42
49
|
pub fn get_or_compile(&self, schema: &Value) -> Result<Arc<SchemaValidator>, String> {
|
|
43
|
-
let key = serde_json::to_string(schema).map_err(|e| format!("Failed to serialize schema: {}"
|
|
50
|
+
let key = serde_json::to_string(schema).map_err(|e| format!("Failed to serialize schema: {e}"))?;
|
|
44
51
|
|
|
45
52
|
{
|
|
46
53
|
let schemas = self.schemas.read().unwrap();
|
|
@@ -62,10 +69,14 @@ impl SchemaRegistry {
|
|
|
62
69
|
Ok(validator)
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
/// Get all registered schemas (for OpenAPI generation)
|
|
72
|
+
/// Get all registered schemas (for `OpenAPI` generation)
|
|
66
73
|
///
|
|
67
74
|
/// Returns a snapshot of all compiled validators.
|
|
68
|
-
/// Useful for generating OpenAPI specifications from runtime schema information.
|
|
75
|
+
/// Useful for generating `OpenAPI` specifications from runtime schema information.
|
|
76
|
+
///
|
|
77
|
+
/// # Panics
|
|
78
|
+
/// Panics if the read lock is poisoned.
|
|
79
|
+
#[must_use]
|
|
69
80
|
pub fn all_schemas(&self) -> Vec<Arc<SchemaValidator>> {
|
|
70
81
|
let schemas = self.schemas.read().unwrap();
|
|
71
82
|
schemas.values().cloned().collect()
|
|
@@ -74,6 +85,10 @@ impl SchemaRegistry {
|
|
|
74
85
|
/// Get the number of unique schemas registered
|
|
75
86
|
///
|
|
76
87
|
/// Useful for diagnostics and understanding schema deduplication effectiveness.
|
|
88
|
+
///
|
|
89
|
+
/// # Panics
|
|
90
|
+
/// Panics if the read lock is poisoned.
|
|
91
|
+
#[must_use]
|
|
77
92
|
pub fn schema_count(&self) -> usize {
|
|
78
93
|
let schemas = self.schemas.read().unwrap();
|
|
79
94
|
schemas.len()
|
|
@@ -164,16 +179,15 @@ mod tests {
|
|
|
164
179
|
}
|
|
165
180
|
});
|
|
166
181
|
|
|
167
|
-
let
|
|
182
|
+
let validators: Vec<_> = (0..10)
|
|
168
183
|
.map(|_| {
|
|
169
184
|
let registry = StdArc::clone(®istry);
|
|
170
185
|
let schema = schema.clone();
|
|
171
186
|
thread::spawn(move || registry.get_or_compile(&schema).unwrap())
|
|
172
187
|
})
|
|
188
|
+
.map(|h| h.join().unwrap())
|
|
173
189
|
.collect();
|
|
174
190
|
|
|
175
|
-
let validators: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
176
|
-
|
|
177
191
|
for i in 1..validators.len() {
|
|
178
192
|
assert!(Arc::ptr_eq(&validators[0], &validators[i]));
|
|
179
193
|
}
|
|
@@ -39,6 +39,10 @@ fn path_type_regex() -> &'static Regex {
|
|
|
39
39
|
/// assert_eq!(hints.get("id"), Some(&"uuid".to_string()));
|
|
40
40
|
/// assert_eq!(hints.get("tag_id"), Some(&"int".to_string()));
|
|
41
41
|
/// ```
|
|
42
|
+
///
|
|
43
|
+
/// # Panics
|
|
44
|
+
/// Panics if regex capture groups don't contain expected indices.
|
|
45
|
+
#[must_use]
|
|
42
46
|
pub fn parse_type_hints(route_path: &str) -> HashMap<String, String> {
|
|
43
47
|
let mut hints = HashMap::new();
|
|
44
48
|
let re = type_hint_regex();
|
|
@@ -66,6 +70,7 @@ pub fn parse_type_hints(route_path: &str) -> HashMap<String, String> {
|
|
|
66
70
|
/// assert_eq!(strip_type_hints("/items/{id:uuid}"), "/items/{id}");
|
|
67
71
|
/// assert_eq!(strip_type_hints("/files/{path:path}"), "/files/{*path}");
|
|
68
72
|
/// ```
|
|
73
|
+
#[must_use]
|
|
69
74
|
pub fn strip_type_hints(route_path: &str) -> String {
|
|
70
75
|
let path_re = path_type_regex();
|
|
71
76
|
let route_path = path_re.replace_all(route_path, "{*$1}");
|
|
@@ -86,6 +91,8 @@ pub fn strip_type_hints(route_path: &str) -> String {
|
|
|
86
91
|
/// - `date` → `{"type": "string", "format": "date"}`
|
|
87
92
|
/// - `datetime` → `{"type": "string", "format": "date-time"}`
|
|
88
93
|
/// - `path` → `{"type": "string"}` (wildcard capture)
|
|
94
|
+
#[must_use]
|
|
95
|
+
#[allow(clippy::match_same_arms)]
|
|
89
96
|
pub fn type_hint_to_schema(type_hint: &str) -> Value {
|
|
90
97
|
match type_hint {
|
|
91
98
|
"uuid" => json!({
|
|
@@ -95,7 +102,7 @@ pub fn type_hint_to_schema(type_hint: &str) -> Value {
|
|
|
95
102
|
"int" | "integer" => json!({
|
|
96
103
|
"type": "integer"
|
|
97
104
|
}),
|
|
98
|
-
"str" | "string" => json!({
|
|
105
|
+
"str" | "string" | "path" => json!({
|
|
99
106
|
"type": "string"
|
|
100
107
|
}),
|
|
101
108
|
"float" | "number" => json!({
|
|
@@ -112,9 +119,6 @@ pub fn type_hint_to_schema(type_hint: &str) -> Value {
|
|
|
112
119
|
"type": "string",
|
|
113
120
|
"format": "date-time"
|
|
114
121
|
}),
|
|
115
|
-
"path" => json!({
|
|
116
|
-
"type": "string"
|
|
117
|
-
}),
|
|
118
122
|
_ => json!({
|
|
119
123
|
"type": "string"
|
|
120
124
|
}),
|
|
@@ -145,6 +149,7 @@ pub fn type_hint_to_schema(type_hint: &str) -> Value {
|
|
|
145
149
|
/// "required": ["id"]
|
|
146
150
|
/// })));
|
|
147
151
|
/// ```
|
|
152
|
+
#[must_use]
|
|
148
153
|
pub fn auto_generate_parameter_schema(route_path: &str) -> Option<Value> {
|
|
149
154
|
let type_hints = parse_type_hints(route_path);
|
|
150
155
|
|
|
@@ -201,10 +206,11 @@ pub fn auto_generate_parameter_schema(route_path: &str) -> Option<Value> {
|
|
|
201
206
|
/// "required": ["count"]
|
|
202
207
|
/// });
|
|
203
208
|
///
|
|
204
|
-
/// let merged = merge_parameter_schemas(auto_schema, explicit_schema);
|
|
209
|
+
/// let merged = merge_parameter_schemas(&auto_schema, &explicit_schema);
|
|
205
210
|
/// // Result: auto-generated id + explicit count with constraints
|
|
206
211
|
/// ```
|
|
207
|
-
|
|
212
|
+
#[must_use]
|
|
213
|
+
pub fn merge_parameter_schemas(auto_schema: &Value, explicit_schema: &Value) -> Value {
|
|
208
214
|
let mut result = auto_schema.clone();
|
|
209
215
|
|
|
210
216
|
let auto_props = result.get_mut("properties").and_then(|v| v.as_object_mut());
|
|
@@ -229,6 +235,7 @@ pub fn merge_parameter_schemas(auto_schema: Value, explicit_schema: Value) -> Va
|
|
|
229
235
|
result
|
|
230
236
|
}
|
|
231
237
|
|
|
238
|
+
#[allow(clippy::literal_string_with_formatting_args)]
|
|
232
239
|
#[cfg(test)]
|
|
233
240
|
mod tests {
|
|
234
241
|
use super::*;
|
|
@@ -296,7 +303,7 @@ mod tests {
|
|
|
296
303
|
"required": ["count"]
|
|
297
304
|
});
|
|
298
305
|
|
|
299
|
-
let merged = merge_parameter_schemas(auto_schema, explicit_schema);
|
|
306
|
+
let merged = merge_parameter_schemas(&auto_schema, &explicit_schema);
|
|
300
307
|
assert!(merged["properties"]["id"].is_object());
|
|
301
308
|
assert!(merged["properties"]["count"].is_object());
|
|
302
309
|
assert_eq!(merged["properties"]["count"]["minimum"], 1);
|
|
@@ -51,31 +51,35 @@ pub enum ErrorCondition {
|
|
|
51
51
|
|
|
52
52
|
impl ErrorCondition {
|
|
53
53
|
/// Determine the error condition from schema path and error message
|
|
54
|
+
#[must_use]
|
|
55
|
+
#[allow(clippy::ignored_unit_patterns)]
|
|
54
56
|
pub fn from_schema_error(schema_path_str: &str, error_msg: &str) -> Self {
|
|
55
57
|
match () {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
() if schema_path_str.contains("minLength") => Self::StringTooShort { min_length: None },
|
|
59
|
+
() if schema_path_str.contains("maxLength") => Self::StringTooLong { max_length: None },
|
|
60
|
+
() if schema_path_str.contains("exclusiveMinimum")
|
|
59
61
|
|| (error_msg.contains("less than or equal to") && error_msg.contains("minimum")) =>
|
|
60
62
|
{
|
|
61
63
|
Self::GreaterThan { value: None }
|
|
62
64
|
}
|
|
63
|
-
|
|
65
|
+
() if schema_path_str.contains("minimum") || error_msg.contains("less than the minimum") => {
|
|
64
66
|
Self::GreaterThanEqual { value: None }
|
|
65
67
|
}
|
|
66
|
-
|
|
68
|
+
() if schema_path_str.contains("exclusiveMaximum")
|
|
67
69
|
|| (error_msg.contains("greater than or equal to") && error_msg.contains("maximum")) =>
|
|
68
70
|
{
|
|
69
71
|
Self::LessThan { value: None }
|
|
70
72
|
}
|
|
71
|
-
|
|
73
|
+
() if schema_path_str.contains("maximum") || error_msg.contains("greater than the maximum") => {
|
|
72
74
|
Self::LessThanEqual { value: None }
|
|
73
75
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
() if schema_path_str.contains("enum") || error_msg.contains("is not one of") => {
|
|
77
|
+
Self::Enum { values: None }
|
|
78
|
+
}
|
|
79
|
+
() if schema_path_str.contains("pattern") || error_msg.contains("does not match") => {
|
|
76
80
|
Self::StringPatternMismatch { pattern: None }
|
|
77
81
|
}
|
|
78
|
-
|
|
82
|
+
() if schema_path_str.contains("format") => {
|
|
79
83
|
if error_msg.contains("email") {
|
|
80
84
|
Self::EmailFormat
|
|
81
85
|
} else if error_msg.contains("uuid") {
|
|
@@ -104,7 +108,8 @@ impl ErrorCondition {
|
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/// Get the error type code for this condition
|
|
107
|
-
|
|
111
|
+
#[must_use]
|
|
112
|
+
pub const fn error_type(&self) -> &'static str {
|
|
108
113
|
match self {
|
|
109
114
|
Self::StringTooShort { .. } => "string_too_short",
|
|
110
115
|
Self::StringTooLong { .. } => "string_too_long",
|
|
@@ -113,23 +118,22 @@ impl ErrorCondition {
|
|
|
113
118
|
Self::LessThan { .. } => "less_than",
|
|
114
119
|
Self::LessThanEqual { .. } => "less_than_equal",
|
|
115
120
|
Self::Enum { .. } => "enum",
|
|
116
|
-
Self::StringPatternMismatch { .. } => "string_pattern_mismatch",
|
|
117
|
-
Self::EmailFormat => "string_pattern_mismatch",
|
|
121
|
+
Self::StringPatternMismatch { .. } | Self::EmailFormat => "string_pattern_mismatch",
|
|
118
122
|
Self::UuidFormat => "uuid_parsing",
|
|
119
123
|
Self::DatetimeFormat => "datetime_parsing",
|
|
120
124
|
Self::DateFormat => "date_parsing",
|
|
121
125
|
Self::FormatError => "format_error",
|
|
122
126
|
Self::TypeMismatch { .. } => "type_error",
|
|
123
127
|
Self::Missing => "missing",
|
|
124
|
-
Self::AdditionalProperties { .. } => "validation_error",
|
|
128
|
+
Self::AdditionalProperties { .. } | Self::ValidationError => "validation_error",
|
|
125
129
|
Self::TooFewItems { .. } => "too_short",
|
|
126
130
|
Self::TooManyItems => "too_long",
|
|
127
|
-
Self::ValidationError => "validation_error",
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/// Get default message for this error condition
|
|
132
|
-
|
|
135
|
+
#[must_use]
|
|
136
|
+
pub const fn default_message(&self) -> &'static str {
|
|
133
137
|
match self {
|
|
134
138
|
Self::StringTooShort { .. } => "String is too short",
|
|
135
139
|
Self::StringTooLong { .. } => "String is too long",
|
|
@@ -159,6 +163,16 @@ pub struct ErrorMapper;
|
|
|
159
163
|
|
|
160
164
|
impl ErrorMapper {
|
|
161
165
|
/// Map an error condition to its type, message, and context
|
|
166
|
+
///
|
|
167
|
+
/// # Panics
|
|
168
|
+
/// Panics if accessing `.last()` on an empty vector for enum values extraction.
|
|
169
|
+
#[must_use]
|
|
170
|
+
#[allow(
|
|
171
|
+
clippy::too_many_lines,
|
|
172
|
+
clippy::option_if_let_else,
|
|
173
|
+
clippy::redundant_closure_for_method_calls,
|
|
174
|
+
clippy::uninlined_format_args
|
|
175
|
+
)]
|
|
162
176
|
pub fn map_error(
|
|
163
177
|
condition: &ErrorCondition,
|
|
164
178
|
schema: &Value,
|
|
@@ -661,7 +675,7 @@ mod tests {
|
|
|
661
675
|
let condition = ErrorCondition::EmailFormat;
|
|
662
676
|
let (error_type, msg, ctx) = ErrorMapper::map_error(&condition, &schema, "", "");
|
|
663
677
|
assert_eq!(error_type, "string_pattern_mismatch");
|
|
664
|
-
assert!(msg.contains(
|
|
678
|
+
assert!(msg.contains('@'));
|
|
665
679
|
assert!(ctx.is_some());
|
|
666
680
|
}
|
|
667
681
|
|
|
@@ -18,6 +18,9 @@ pub struct SchemaValidator {
|
|
|
18
18
|
|
|
19
19
|
impl SchemaValidator {
|
|
20
20
|
/// Create a new validator from a JSON Schema
|
|
21
|
+
///
|
|
22
|
+
/// # Errors
|
|
23
|
+
/// Returns an error if the schema is invalid or compilation fails.
|
|
21
24
|
pub fn new(schema: Value) -> Result<Self, String> {
|
|
22
25
|
let compiled = jsonschema::options()
|
|
23
26
|
.with_draft(jsonschema::Draft::Draft202012)
|
|
@@ -26,7 +29,7 @@ impl SchemaValidator {
|
|
|
26
29
|
.build(&schema)
|
|
27
30
|
.map_err(|e| {
|
|
28
31
|
anyhow::anyhow!("Invalid JSON Schema")
|
|
29
|
-
.context(format!("Schema compilation failed: {}"
|
|
32
|
+
.context(format!("Schema compilation failed: {e}"))
|
|
30
33
|
.to_string()
|
|
31
34
|
})?;
|
|
32
35
|
|
|
@@ -37,16 +40,17 @@ impl SchemaValidator {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/// Get the underlying JSON Schema
|
|
40
|
-
|
|
43
|
+
#[must_use]
|
|
44
|
+
pub const fn schema(&self) -> &Value {
|
|
41
45
|
&self.schema
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
/// Pre-process data to convert file objects to strings for format:
|
|
48
|
+
/// Pre-process data to convert file objects to strings for format: `binary` validation
|
|
45
49
|
///
|
|
46
50
|
/// Files uploaded via multipart are converted to objects like:
|
|
47
|
-
/// {"filename": "...", "size": N, "content": "...", "content_type": "..."}
|
|
51
|
+
/// `{"filename": "...", "size": N, "content": "...", "content_type": "..."}`
|
|
48
52
|
///
|
|
49
|
-
/// But schemas define them as: {"type": "string", "format": "binary"}
|
|
53
|
+
/// But schemas define them as: `{"type": "string", "format": "binary"}`
|
|
50
54
|
///
|
|
51
55
|
/// This method recursively processes the data and converts file objects to their content strings
|
|
52
56
|
/// so that validation passes, while preserving the original structure for handlers to use.
|
|
@@ -54,7 +58,7 @@ impl SchemaValidator {
|
|
|
54
58
|
self.preprocess_value_with_schema(data, &self.schema)
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
#[allow(clippy::only_used_in_recursion)]
|
|
61
|
+
#[allow(clippy::only_used_in_recursion, clippy::self_only_used_in_recursion)]
|
|
58
62
|
fn preprocess_value_with_schema(&self, data: &Value, schema: &Value) -> Value {
|
|
59
63
|
if let Some(schema_obj) = schema.as_object() {
|
|
60
64
|
let is_string_type = schema_obj.get("type").and_then(|t| t.as_str()) == Some("string");
|
|
@@ -110,6 +114,13 @@ impl SchemaValidator {
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
/// Validate JSON data against the schema
|
|
117
|
+
///
|
|
118
|
+
/// # Errors
|
|
119
|
+
/// Returns a `ValidationError` if the data does not conform to the schema.
|
|
120
|
+
///
|
|
121
|
+
/// # Too Many Lines
|
|
122
|
+
/// This function is complex due to error mapping logic.
|
|
123
|
+
#[allow(clippy::option_if_let_else, clippy::uninlined_format_args, clippy::too_many_lines)]
|
|
113
124
|
pub fn validate(&self, data: &Value) -> Result<(), ValidationError> {
|
|
114
125
|
let processed_data = self.preprocess_binary_fields(data);
|
|
115
126
|
|
|
@@ -131,41 +142,39 @@ impl SchemaValidator {
|
|
|
131
142
|
if let Some(end) = error_msg[start + 1..].find('"') {
|
|
132
143
|
error_msg[start + 1..start + 1 + end].to_string()
|
|
133
144
|
} else {
|
|
134
|
-
|
|
145
|
+
String::new()
|
|
135
146
|
}
|
|
136
147
|
} else {
|
|
137
|
-
|
|
148
|
+
String::new()
|
|
138
149
|
};
|
|
139
150
|
|
|
140
|
-
if
|
|
151
|
+
if instance_path.starts_with('/') && instance_path.len() > 1 {
|
|
141
152
|
let base_path = &instance_path[1..];
|
|
142
|
-
if
|
|
143
|
-
format!("{}/{}", base_path, field_name)
|
|
144
|
-
} else {
|
|
153
|
+
if field_name.is_empty() {
|
|
145
154
|
base_path.to_string()
|
|
155
|
+
} else {
|
|
156
|
+
format!("{base_path}/{field_name}")
|
|
146
157
|
}
|
|
147
|
-
} else if
|
|
148
|
-
field_name
|
|
149
|
-
} else {
|
|
158
|
+
} else if field_name.is_empty() {
|
|
150
159
|
"body".to_string()
|
|
160
|
+
} else {
|
|
161
|
+
field_name
|
|
151
162
|
}
|
|
152
163
|
} else if schema_path_str.contains("/additionalProperties") {
|
|
153
164
|
if let Some(start) = error_msg.find('(') {
|
|
154
165
|
if let Some(quote_start) = error_msg[start..].find('\'') {
|
|
155
166
|
let abs_start = start + quote_start + 1;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
&& instance_path.len() > 1
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
instance_path[1..].to_string()
|
|
168
|
-
}
|
|
167
|
+
error_msg[abs_start..].find('\'').map_or_else(
|
|
168
|
+
|| instance_path[1..].to_string(),
|
|
169
|
+
|quote_end| {
|
|
170
|
+
let property_name = error_msg[abs_start..abs_start + quote_end].to_string();
|
|
171
|
+
if instance_path.starts_with('/') && instance_path.len() > 1 {
|
|
172
|
+
format!("{}/{property_name}", &instance_path[1..])
|
|
173
|
+
} else {
|
|
174
|
+
property_name
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
)
|
|
169
178
|
} else {
|
|
170
179
|
instance_path[1..].to_string()
|
|
171
180
|
}
|
|
@@ -184,7 +193,7 @@ impl SchemaValidator {
|
|
|
184
193
|
|
|
185
194
|
let loc_parts: Vec<String> = if param_name.contains('/') {
|
|
186
195
|
let mut parts = vec!["body".to_string()];
|
|
187
|
-
parts.extend(param_name.split('/').map(
|
|
196
|
+
parts.extend(param_name.split('/').map(ToString::to_string));
|
|
188
197
|
parts
|
|
189
198
|
} else if param_name == "body" {
|
|
190
199
|
vec!["body".to_string()]
|
|
@@ -201,7 +210,7 @@ impl SchemaValidator {
|
|
|
201
210
|
let schema_prop_path = if param_name.contains('/') {
|
|
202
211
|
format!("/properties/{}", param_name.replace('/', "/properties/"))
|
|
203
212
|
} else {
|
|
204
|
-
format!("/properties/{}"
|
|
213
|
+
format!("/properties/{param_name}")
|
|
205
214
|
};
|
|
206
215
|
|
|
207
216
|
let mut error_condition = ErrorCondition::from_schema_error(schema_path_str, &error_msg);
|
|
@@ -210,13 +219,14 @@ impl SchemaValidator {
|
|
|
210
219
|
ErrorCondition::TypeMismatch { .. } => {
|
|
211
220
|
let expected_type = self
|
|
212
221
|
.schema
|
|
213
|
-
.pointer(&format!("{}/type"
|
|
222
|
+
.pointer(&format!("{schema_prop_path}/type"))
|
|
214
223
|
.and_then(|v| v.as_str())
|
|
215
224
|
.unwrap_or("unknown")
|
|
216
225
|
.to_string();
|
|
217
226
|
ErrorCondition::TypeMismatch { expected_type }
|
|
218
227
|
}
|
|
219
228
|
ErrorCondition::AdditionalProperties { .. } => {
|
|
229
|
+
#[allow(clippy::redundant_clone)]
|
|
220
230
|
let unexpected_field = if param_name.contains('/') {
|
|
221
231
|
param_name.split('/').next_back().unwrap_or(¶m_name).to_string()
|
|
222
232
|
} else {
|
|
@@ -268,12 +278,15 @@ impl SchemaValidator {
|
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
/// Validate and parse JSON bytes
|
|
281
|
+
///
|
|
282
|
+
/// # Errors
|
|
283
|
+
/// Returns a validation error if the JSON is invalid or fails validation against the schema.
|
|
271
284
|
pub fn validate_json(&self, json_bytes: &[u8]) -> Result<Value, ValidationError> {
|
|
272
285
|
let value: Value = serde_json::from_slice(json_bytes).map_err(|e| ValidationError {
|
|
273
286
|
errors: vec![ValidationErrorDetail {
|
|
274
287
|
error_type: "json_parse_error".to_string(),
|
|
275
288
|
loc: vec!["body".to_string()],
|
|
276
|
-
msg: format!("Invalid JSON: {}"
|
|
289
|
+
msg: format!("Invalid JSON: {e}"),
|
|
277
290
|
input: Value::Null,
|
|
278
291
|
ctx: None,
|
|
279
292
|
}],
|
|
@@ -447,7 +460,7 @@ mod tests {
|
|
|
447
460
|
});
|
|
448
461
|
|
|
449
462
|
let result = validator.validate(&data);
|
|
450
|
-
eprintln!("Validation result: {:?}"
|
|
463
|
+
eprintln!("Validation result: {result:?}");
|
|
451
464
|
|
|
452
465
|
assert!(result.is_err(), "Should have validation errors");
|
|
453
466
|
let err = result.unwrap_err();
|
|
@@ -466,7 +466,7 @@ fn test_error_context_includes_constraints() {
|
|
|
466
466
|
let err = result.unwrap_err();
|
|
467
467
|
assert!(err.errors[0].ctx.is_some());
|
|
468
468
|
let ctx = err.errors[0].ctx.as_ref().unwrap();
|
|
469
|
-
assert_eq!(ctx.get("min_length").and_then(
|
|
469
|
+
assert_eq!(ctx.get("min_length").and_then(serde_json::Value::as_u64), Some(5));
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
#[test]
|
|
@@ -631,7 +631,7 @@ fn test_error_messages_are_user_friendly() {
|
|
|
631
631
|
let msg = &err.errors[0].msg;
|
|
632
632
|
assert!(msg.contains("18") || msg.contains("minimum"));
|
|
633
633
|
assert!(!msg.contains("exclusiveMinimum"));
|
|
634
|
-
assert!(!msg.contains(
|
|
634
|
+
assert!(!msg.contains('$'));
|
|
635
635
|
}
|
|
636
636
|
|
|
637
637
|
#[test]
|
|
@@ -39,7 +39,7 @@ fn test_query_boolean_empty_string_coerces_to_false() {
|
|
|
39
39
|
let validator = ParameterValidator::new(schema).expect("validator");
|
|
40
40
|
|
|
41
41
|
let mut raw_query_params = HashMap::new();
|
|
42
|
-
raw_query_params.insert("flag".to_string(), vec![
|
|
42
|
+
raw_query_params.insert("flag".to_string(), vec![String::new()]);
|
|
43
43
|
|
|
44
44
|
let extracted = validator.validate_and_extract(
|
|
45
45
|
&json!({}),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//! Comprehensive parameter validation tests
|
|
2
2
|
//!
|
|
3
3
|
//! These tests cover header, cookie, and query parameter validation scenarios
|
|
4
|
-
//! using the ParameterValidator from spikard-core.
|
|
4
|
+
//! using the `ParameterValidator` from spikard-core.
|
|
5
5
|
|
|
6
6
|
use serde_json::json;
|
|
7
7
|
use spikard_core::parameters::ParameterValidator;
|