spikard 0.4.0-arm64-darwin-23
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 +7 -0
- data/LICENSE +1 -0
- data/README.md +659 -0
- data/ext/spikard_rb/Cargo.toml +17 -0
- data/ext/spikard_rb/extconf.rb +10 -0
- data/ext/spikard_rb/src/lib.rs +6 -0
- data/lib/spikard/app.rb +405 -0
- data/lib/spikard/background.rb +27 -0
- data/lib/spikard/config.rb +396 -0
- data/lib/spikard/converters.rb +13 -0
- data/lib/spikard/handler_wrapper.rb +113 -0
- data/lib/spikard/provide.rb +214 -0
- data/lib/spikard/response.rb +173 -0
- data/lib/spikard/schema.rb +243 -0
- data/lib/spikard/sse.rb +111 -0
- data/lib/spikard/streaming_response.rb +44 -0
- data/lib/spikard/testing.rb +221 -0
- data/lib/spikard/upload_file.rb +131 -0
- data/lib/spikard/version.rb +5 -0
- data/lib/spikard/websocket.rb +59 -0
- data/lib/spikard.rb +43 -0
- data/sig/spikard.rbs +366 -0
- data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
- data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
- data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
- data/vendor/crates/spikard-core/Cargo.toml +40 -0
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
- data/vendor/crates/spikard-core/src/debug.rs +63 -0
- data/vendor/crates/spikard-core/src/di/container.rs +726 -0
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
- data/vendor/crates/spikard-core/src/di/error.rs +118 -0
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
- data/vendor/crates/spikard-core/src/di/value.rs +283 -0
- data/vendor/crates/spikard-core/src/errors.rs +39 -0
- data/vendor/crates/spikard-core/src/http.rs +153 -0
- data/vendor/crates/spikard-core/src/lib.rs +29 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
- data/vendor/crates/spikard-core/src/metadata.rs +397 -0
- data/vendor/crates/spikard-core/src/parameters.rs +723 -0
- data/vendor/crates/spikard-core/src/problem.rs +310 -0
- data/vendor/crates/spikard-core/src/request_data.rs +189 -0
- data/vendor/crates/spikard-core/src/router.rs +249 -0
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
- data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
- data/vendor/crates/spikard-http/Cargo.toml +58 -0
- data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
- data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
- data/vendor/crates/spikard-http/src/auth.rs +247 -0
- data/vendor/crates/spikard-http/src/background.rs +1562 -0
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
- data/vendor/crates/spikard-http/src/cors.rs +490 -0
- data/vendor/crates/spikard-http/src/debug.rs +63 -0
- data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
- data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
- data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
- data/vendor/crates/spikard-http/src/lib.rs +524 -0
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
- data/vendor/crates/spikard-http/src/response.rs +399 -0
- data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
- data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
- data/vendor/crates/spikard-http/src/sse.rs +961 -0
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
- data/vendor/crates/spikard-http/src/testing.rs +377 -0
- data/vendor/crates/spikard-http/src/websocket.rs +831 -0
- data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
- data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
- data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
- data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
- data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
- data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
- data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
- data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
- data/vendor/crates/spikard-rb/Cargo.toml +43 -0
- data/vendor/crates/spikard-rb/build.rs +199 -0
- data/vendor/crates/spikard-rb/src/background.rs +63 -0
- data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
- data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
- data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
- data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
- data/vendor/crates/spikard-rb/src/handler.rs +612 -0
- data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
- data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
- data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
- data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
- data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
- data/vendor/crates/spikard-rb/src/server.rs +283 -0
- data/vendor/crates/spikard-rb/src/sse.rs +231 -0
- data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
- data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
- data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
- data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
- metadata +213 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
//! Request/response validation using JSON Schema
|
|
2
|
+
|
|
3
|
+
pub mod error_mapper;
|
|
4
|
+
|
|
5
|
+
use crate::debug_log_module;
|
|
6
|
+
use jsonschema::Validator;
|
|
7
|
+
use serde_json::Value;
|
|
8
|
+
use std::sync::Arc;
|
|
9
|
+
|
|
10
|
+
use self::error_mapper::{ErrorCondition, ErrorMapper};
|
|
11
|
+
|
|
12
|
+
/// Schema validator that compiles and validates JSON Schema
|
|
13
|
+
#[derive(Clone)]
|
|
14
|
+
pub struct SchemaValidator {
|
|
15
|
+
compiled: Arc<Validator>,
|
|
16
|
+
schema: Value,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
impl SchemaValidator {
|
|
20
|
+
/// Create a new validator from a JSON Schema
|
|
21
|
+
pub fn new(schema: Value) -> Result<Self, String> {
|
|
22
|
+
let compiled = jsonschema::options()
|
|
23
|
+
.with_draft(jsonschema::Draft::Draft202012)
|
|
24
|
+
.should_validate_formats(true)
|
|
25
|
+
.with_pattern_options(jsonschema::PatternOptions::regex())
|
|
26
|
+
.build(&schema)
|
|
27
|
+
.map_err(|e| {
|
|
28
|
+
anyhow::anyhow!("Invalid JSON Schema")
|
|
29
|
+
.context(format!("Schema compilation failed: {}", e))
|
|
30
|
+
.to_string()
|
|
31
|
+
})?;
|
|
32
|
+
|
|
33
|
+
Ok(Self {
|
|
34
|
+
compiled: Arc::new(compiled),
|
|
35
|
+
schema,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Get the underlying JSON Schema
|
|
40
|
+
pub fn schema(&self) -> &Value {
|
|
41
|
+
&self.schema
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Pre-process data to convert file objects to strings for format: "binary" validation
|
|
45
|
+
///
|
|
46
|
+
/// Files uploaded via multipart are converted to objects like:
|
|
47
|
+
/// {"filename": "...", "size": N, "content": "...", "content_type": "..."}
|
|
48
|
+
///
|
|
49
|
+
/// But schemas define them as: {"type": "string", "format": "binary"}
|
|
50
|
+
///
|
|
51
|
+
/// This method recursively processes the data and converts file objects to their content strings
|
|
52
|
+
/// so that validation passes, while preserving the original structure for handlers to use.
|
|
53
|
+
fn preprocess_binary_fields(&self, data: &Value) -> Value {
|
|
54
|
+
self.preprocess_value_with_schema(data, &self.schema)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[allow(clippy::only_used_in_recursion)]
|
|
58
|
+
fn preprocess_value_with_schema(&self, data: &Value, schema: &Value) -> Value {
|
|
59
|
+
if let Some(schema_obj) = schema.as_object() {
|
|
60
|
+
let is_string_type = schema_obj.get("type").and_then(|t| t.as_str()) == Some("string");
|
|
61
|
+
let is_binary_format = schema_obj.get("format").and_then(|f| f.as_str()) == Some("binary");
|
|
62
|
+
|
|
63
|
+
#[allow(clippy::collapsible_if)]
|
|
64
|
+
if is_string_type && is_binary_format {
|
|
65
|
+
if let Some(data_obj) = data.as_object() {
|
|
66
|
+
if data_obj.contains_key("filename")
|
|
67
|
+
&& data_obj.contains_key("content")
|
|
68
|
+
&& data_obj.contains_key("size")
|
|
69
|
+
&& data_obj.contains_key("content_type")
|
|
70
|
+
{
|
|
71
|
+
return data_obj.get("content").unwrap_or(&Value::Null).clone();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return data.clone();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[allow(clippy::collapsible_if)]
|
|
78
|
+
if schema_obj.get("type").and_then(|t| t.as_str()) == Some("array") {
|
|
79
|
+
if let Some(items_schema) = schema_obj.get("items") {
|
|
80
|
+
if let Some(data_array) = data.as_array() {
|
|
81
|
+
let processed_array: Vec<Value> = data_array
|
|
82
|
+
.iter()
|
|
83
|
+
.map(|item| self.preprocess_value_with_schema(item, items_schema))
|
|
84
|
+
.collect();
|
|
85
|
+
return Value::Array(processed_array);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[allow(clippy::collapsible_if)]
|
|
91
|
+
if schema_obj.get("type").and_then(|t| t.as_str()) == Some("object") {
|
|
92
|
+
if let Some(properties) = schema_obj.get("properties").and_then(|p| p.as_object()) {
|
|
93
|
+
if let Some(data_obj) = data.as_object() {
|
|
94
|
+
let mut processed_obj = serde_json::Map::new();
|
|
95
|
+
for (key, value) in data_obj {
|
|
96
|
+
if let Some(prop_schema) = properties.get(key) {
|
|
97
|
+
processed_obj
|
|
98
|
+
.insert(key.clone(), self.preprocess_value_with_schema(value, prop_schema));
|
|
99
|
+
} else {
|
|
100
|
+
processed_obj.insert(key.clone(), value.clone());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Value::Object(processed_obj);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
data.clone()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Validate JSON data against the schema
|
|
113
|
+
pub fn validate(&self, data: &Value) -> Result<(), ValidationError> {
|
|
114
|
+
let processed_data = self.preprocess_binary_fields(data);
|
|
115
|
+
|
|
116
|
+
let validation_errors: Vec<_> = self.compiled.iter_errors(&processed_data).collect();
|
|
117
|
+
|
|
118
|
+
if validation_errors.is_empty() {
|
|
119
|
+
return Ok(());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let errors: Vec<ValidationErrorDetail> = validation_errors
|
|
123
|
+
.into_iter()
|
|
124
|
+
.map(|err| {
|
|
125
|
+
let instance_path = err.instance_path().to_string();
|
|
126
|
+
let schema_path_str = err.schema_path().as_str();
|
|
127
|
+
let error_msg = err.to_string();
|
|
128
|
+
|
|
129
|
+
let param_name = if schema_path_str.ends_with("/required") {
|
|
130
|
+
let field_name = if let Some(start) = error_msg.find('"') {
|
|
131
|
+
if let Some(end) = error_msg[start + 1..].find('"') {
|
|
132
|
+
error_msg[start + 1..start + 1 + end].to_string()
|
|
133
|
+
} else {
|
|
134
|
+
"".to_string()
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
"".to_string()
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if !instance_path.is_empty() && instance_path.starts_with('/') && instance_path.len() > 1 {
|
|
141
|
+
let base_path = &instance_path[1..];
|
|
142
|
+
if !field_name.is_empty() {
|
|
143
|
+
format!("{}/{}", base_path, field_name)
|
|
144
|
+
} else {
|
|
145
|
+
base_path.to_string()
|
|
146
|
+
}
|
|
147
|
+
} else if !field_name.is_empty() {
|
|
148
|
+
field_name
|
|
149
|
+
} else {
|
|
150
|
+
"body".to_string()
|
|
151
|
+
}
|
|
152
|
+
} else if schema_path_str.contains("/additionalProperties") {
|
|
153
|
+
if let Some(start) = error_msg.find('(') {
|
|
154
|
+
if let Some(quote_start) = error_msg[start..].find('\'') {
|
|
155
|
+
let abs_start = start + quote_start + 1;
|
|
156
|
+
if let Some(quote_end) = error_msg[abs_start..].find('\'') {
|
|
157
|
+
let property_name = error_msg[abs_start..abs_start + quote_end].to_string();
|
|
158
|
+
if !instance_path.is_empty()
|
|
159
|
+
&& instance_path.starts_with('/')
|
|
160
|
+
&& instance_path.len() > 1
|
|
161
|
+
{
|
|
162
|
+
format!("{}/{}", &instance_path[1..], property_name)
|
|
163
|
+
} else {
|
|
164
|
+
property_name
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
instance_path[1..].to_string()
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
instance_path[1..].to_string()
|
|
171
|
+
}
|
|
172
|
+
} else if instance_path.starts_with('/') && instance_path.len() > 1 {
|
|
173
|
+
instance_path[1..].to_string()
|
|
174
|
+
} else {
|
|
175
|
+
"body".to_string()
|
|
176
|
+
}
|
|
177
|
+
} else if instance_path.starts_with('/') && instance_path.len() > 1 {
|
|
178
|
+
instance_path[1..].to_string()
|
|
179
|
+
} else if instance_path.is_empty() {
|
|
180
|
+
"body".to_string()
|
|
181
|
+
} else {
|
|
182
|
+
instance_path
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let loc_parts: Vec<String> = if param_name.contains('/') {
|
|
186
|
+
let mut parts = vec!["body".to_string()];
|
|
187
|
+
parts.extend(param_name.split('/').map(|s| s.to_string()));
|
|
188
|
+
parts
|
|
189
|
+
} else if param_name == "body" {
|
|
190
|
+
vec!["body".to_string()]
|
|
191
|
+
} else {
|
|
192
|
+
vec!["body".to_string(), param_name.clone()]
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
let input_value = if schema_path_str == "/required" {
|
|
196
|
+
data.clone()
|
|
197
|
+
} else {
|
|
198
|
+
err.instance().clone().into_owned()
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let schema_prop_path = if param_name.contains('/') {
|
|
202
|
+
format!("/properties/{}", param_name.replace('/', "/properties/"))
|
|
203
|
+
} else {
|
|
204
|
+
format!("/properties/{}", param_name)
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Use table-driven error mapping
|
|
208
|
+
let mut error_condition = ErrorCondition::from_schema_error(schema_path_str, &error_msg);
|
|
209
|
+
|
|
210
|
+
// Enrich condition with extracted values from schema
|
|
211
|
+
error_condition = match error_condition {
|
|
212
|
+
ErrorCondition::TypeMismatch { .. } => {
|
|
213
|
+
let expected_type = self
|
|
214
|
+
.schema
|
|
215
|
+
.pointer(&format!("{}/type", schema_prop_path))
|
|
216
|
+
.and_then(|v| v.as_str())
|
|
217
|
+
.unwrap_or("unknown")
|
|
218
|
+
.to_string();
|
|
219
|
+
ErrorCondition::TypeMismatch { expected_type }
|
|
220
|
+
}
|
|
221
|
+
ErrorCondition::AdditionalProperties { .. } => {
|
|
222
|
+
let unexpected_field = if param_name.contains('/') {
|
|
223
|
+
param_name.split('/').next_back().unwrap_or(¶m_name).to_string()
|
|
224
|
+
} else {
|
|
225
|
+
param_name.clone()
|
|
226
|
+
};
|
|
227
|
+
ErrorCondition::AdditionalProperties {
|
|
228
|
+
field: unexpected_field,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
other => other,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
let (error_type, msg, ctx) =
|
|
235
|
+
ErrorMapper::map_error(&error_condition, &self.schema, &schema_prop_path, &error_msg);
|
|
236
|
+
|
|
237
|
+
ValidationErrorDetail {
|
|
238
|
+
error_type,
|
|
239
|
+
loc: loc_parts,
|
|
240
|
+
msg,
|
|
241
|
+
input: input_value,
|
|
242
|
+
ctx,
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.collect();
|
|
246
|
+
|
|
247
|
+
debug_log_module!("validation", "Returning {} validation errors", errors.len());
|
|
248
|
+
for (i, error) in errors.iter().enumerate() {
|
|
249
|
+
debug_log_module!(
|
|
250
|
+
"validation",
|
|
251
|
+
" Error {}: type={}, loc={:?}, msg={}, input={}, ctx={:?}",
|
|
252
|
+
i,
|
|
253
|
+
error.error_type,
|
|
254
|
+
error.loc,
|
|
255
|
+
error.msg,
|
|
256
|
+
error.input,
|
|
257
|
+
error.ctx
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
#[allow(clippy::collapsible_if)]
|
|
261
|
+
if crate::debug::is_enabled() {
|
|
262
|
+
if let Ok(json_errors) = serde_json::to_value(&errors) {
|
|
263
|
+
if let Ok(json_str) = serde_json::to_string_pretty(&json_errors) {
|
|
264
|
+
debug_log_module!("validation", "Serialized errors:\n{}", json_str);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
Err(ValidationError { errors })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Validate and parse JSON bytes
|
|
273
|
+
pub fn validate_json(&self, json_bytes: &[u8]) -> Result<Value, ValidationError> {
|
|
274
|
+
let value: Value = serde_json::from_slice(json_bytes).map_err(|e| ValidationError {
|
|
275
|
+
errors: vec![ValidationErrorDetail {
|
|
276
|
+
error_type: "json_parse_error".to_string(),
|
|
277
|
+
loc: vec!["body".to_string()],
|
|
278
|
+
msg: format!("Invalid JSON: {}", e),
|
|
279
|
+
input: Value::Null,
|
|
280
|
+
ctx: None,
|
|
281
|
+
}],
|
|
282
|
+
})?;
|
|
283
|
+
|
|
284
|
+
self.validate(&value)?;
|
|
285
|
+
|
|
286
|
+
Ok(value)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// Validation error containing one or more validation failures
|
|
291
|
+
#[derive(Debug, Clone)]
|
|
292
|
+
pub struct ValidationError {
|
|
293
|
+
pub errors: Vec<ValidationErrorDetail>,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Individual validation error detail (FastAPI-compatible format)
|
|
297
|
+
#[derive(Debug, Clone, serde::Serialize)]
|
|
298
|
+
pub struct ValidationErrorDetail {
|
|
299
|
+
#[serde(rename = "type")]
|
|
300
|
+
pub error_type: String,
|
|
301
|
+
pub loc: Vec<String>,
|
|
302
|
+
pub msg: String,
|
|
303
|
+
pub input: Value,
|
|
304
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
305
|
+
pub ctx: Option<Value>,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
impl std::fmt::Display for ValidationError {
|
|
309
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
310
|
+
write!(f, "Validation failed: {} errors", self.errors.len())
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
impl std::error::Error for ValidationError {}
|
|
315
|
+
|
|
316
|
+
#[cfg(test)]
|
|
317
|
+
mod tests {
|
|
318
|
+
use super::*;
|
|
319
|
+
use serde_json::json;
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn test_validator_creation() {
|
|
323
|
+
let schema = json!({
|
|
324
|
+
"type": "object",
|
|
325
|
+
"properties": {
|
|
326
|
+
"name": {"type": "string"},
|
|
327
|
+
"age": {"type": "integer"}
|
|
328
|
+
},
|
|
329
|
+
"required": ["name"]
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let validator = SchemaValidator::new(schema).unwrap();
|
|
333
|
+
assert!(validator.compiled.is_valid(&json!({"name": "Alice", "age": 30})));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[test]
|
|
337
|
+
fn test_validation_success() {
|
|
338
|
+
let schema = json!({
|
|
339
|
+
"type": "object",
|
|
340
|
+
"properties": {
|
|
341
|
+
"email": {"type": "string", "format": "email"}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
let validator = SchemaValidator::new(schema).unwrap();
|
|
346
|
+
let data = json!({"email": "test@example.com"});
|
|
347
|
+
|
|
348
|
+
assert!(validator.validate(&data).is_ok());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn test_validation_failure() {
|
|
353
|
+
let schema = json!({
|
|
354
|
+
"type": "object",
|
|
355
|
+
"properties": {
|
|
356
|
+
"age": {"type": "integer", "minimum": 0}
|
|
357
|
+
},
|
|
358
|
+
"required": ["age"]
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
let validator = SchemaValidator::new(schema).unwrap();
|
|
362
|
+
let data = json!({"age": -5});
|
|
363
|
+
|
|
364
|
+
assert!(validator.validate(&data).is_err());
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn test_validation_error_serialization() {
|
|
369
|
+
let schema = json!({
|
|
370
|
+
"type": "object",
|
|
371
|
+
"properties": {
|
|
372
|
+
"name": {
|
|
373
|
+
"type": "string",
|
|
374
|
+
"maxLength": 10
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
"required": ["name"]
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let validator = SchemaValidator::new(schema).unwrap();
|
|
381
|
+
let data = json!({"name": "this_is_way_too_long"});
|
|
382
|
+
|
|
383
|
+
let result = validator.validate(&data);
|
|
384
|
+
assert!(result.is_err());
|
|
385
|
+
|
|
386
|
+
let err = result.unwrap_err();
|
|
387
|
+
assert_eq!(err.errors.len(), 1);
|
|
388
|
+
|
|
389
|
+
let error_detail = &err.errors[0];
|
|
390
|
+
assert_eq!(error_detail.error_type, "string_too_long");
|
|
391
|
+
assert_eq!(error_detail.loc, vec!["body", "name"]);
|
|
392
|
+
assert_eq!(error_detail.msg, "String should have at most 10 characters");
|
|
393
|
+
assert_eq!(error_detail.input, Value::String("this_is_way_too_long".to_string()));
|
|
394
|
+
assert_eq!(error_detail.ctx, Some(json!({"max_length": 10})));
|
|
395
|
+
|
|
396
|
+
let json_output = serde_json::to_value(&err.errors).unwrap();
|
|
397
|
+
println!(
|
|
398
|
+
"Serialized JSON: {}",
|
|
399
|
+
serde_json::to_string_pretty(&json_output).unwrap()
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
let serialized_error = &json_output[0];
|
|
403
|
+
assert!(serialized_error.get("type").is_some());
|
|
404
|
+
assert!(serialized_error.get("loc").is_some());
|
|
405
|
+
assert!(serialized_error.get("msg").is_some());
|
|
406
|
+
assert!(
|
|
407
|
+
serialized_error.get("input").is_some(),
|
|
408
|
+
"Missing 'input' field in serialized JSON!"
|
|
409
|
+
);
|
|
410
|
+
assert!(
|
|
411
|
+
serialized_error.get("ctx").is_some(),
|
|
412
|
+
"Missing 'ctx' field in serialized JSON!"
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
assert_eq!(
|
|
416
|
+
serialized_error["input"],
|
|
417
|
+
Value::String("this_is_way_too_long".to_string())
|
|
418
|
+
);
|
|
419
|
+
assert_eq!(serialized_error["ctx"], json!({"max_length": 10}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn test_exclusive_minimum() {
|
|
424
|
+
let schema = json!({
|
|
425
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
426
|
+
"type": "object",
|
|
427
|
+
"required": ["id", "name", "price"],
|
|
428
|
+
"properties": {
|
|
429
|
+
"id": {
|
|
430
|
+
"type": "integer"
|
|
431
|
+
},
|
|
432
|
+
"name": {
|
|
433
|
+
"type": "string",
|
|
434
|
+
"minLength": 3
|
|
435
|
+
},
|
|
436
|
+
"price": {
|
|
437
|
+
"type": "number",
|
|
438
|
+
"exclusiveMinimum": 0
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
let validator = SchemaValidator::new(schema).unwrap();
|
|
444
|
+
|
|
445
|
+
let data = json!({
|
|
446
|
+
"id": 1,
|
|
447
|
+
"name": "X",
|
|
448
|
+
"price": -10
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
let result = validator.validate(&data);
|
|
452
|
+
eprintln!("Validation result: {:?}", result);
|
|
453
|
+
|
|
454
|
+
assert!(result.is_err(), "Should have validation errors");
|
|
455
|
+
let err = result.unwrap_err();
|
|
456
|
+
eprintln!("Errors: {:?}", err.errors);
|
|
457
|
+
assert_eq!(err.errors.len(), 2, "Should have 2 errors");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "spikard-http"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
repository = "https://github.com/Goldziher/spikard"
|
|
8
|
+
homepage = "https://github.com/Goldziher/spikard"
|
|
9
|
+
description = "High-performance HTTP server for Spikard with tower-http middleware stack"
|
|
10
|
+
keywords = ["http", "server", "axum", "tower", "middleware"]
|
|
11
|
+
categories = ["web-programming::http-server", "web-programming"]
|
|
12
|
+
documentation = "https://docs.rs/spikard-http"
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
axum = { version = "0.8", features = ["multipart", "ws"] }
|
|
17
|
+
tokio = { version = "1", features = ["full"] }
|
|
18
|
+
tokio-util = "0.7"
|
|
19
|
+
tower = "0.5"
|
|
20
|
+
tower-http = { version = "0.6.8", features = ["trace", "request-id", "compression-gzip", "compression-br", "timeout", "limit", "fs", "set-header", "sensitive-headers"] }
|
|
21
|
+
tower_governor = "0.8"
|
|
22
|
+
jsonwebtoken = { version = "10.2", features = ["use_pem", "rust_crypto"] }
|
|
23
|
+
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
|
|
24
|
+
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
|
25
|
+
utoipa-redoc = { version = "6", features = ["axum"] }
|
|
26
|
+
serde = { version = "1.0", features = ["derive"] }
|
|
27
|
+
serde_json = "1.0"
|
|
28
|
+
tracing = "0.1"
|
|
29
|
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
30
|
+
spikard-core = { path = "../spikard-core" }
|
|
31
|
+
futures-util = "0.3"
|
|
32
|
+
futures = "0.3"
|
|
33
|
+
jsonschema = { version = "0.37", default-features = false }
|
|
34
|
+
serde_qs = "0.15"
|
|
35
|
+
lazy_static = "1.5"
|
|
36
|
+
regex = "1"
|
|
37
|
+
rustc-hash = "2.1"
|
|
38
|
+
urlencoding = "2.1"
|
|
39
|
+
mime = "0.3"
|
|
40
|
+
jiff = "0.2"
|
|
41
|
+
uuid = "1.19"
|
|
42
|
+
bytes = "1.11"
|
|
43
|
+
http-body-util = "0.1"
|
|
44
|
+
http-body = "1.0"
|
|
45
|
+
axum-test = { version = "18", features = ["ws"] }
|
|
46
|
+
anyhow = "1.0"
|
|
47
|
+
cookie = "0.18"
|
|
48
|
+
base64 = "0.22.1"
|
|
49
|
+
flate2 = { version = "=1.1.5", default-features = false, features = ["rust_backend"] }
|
|
50
|
+
brotli = "8.0"
|
|
51
|
+
|
|
52
|
+
[features]
|
|
53
|
+
default = []
|
|
54
|
+
di = ["spikard-core/di"]
|
|
55
|
+
|
|
56
|
+
[dev-dependencies]
|
|
57
|
+
chrono = "0.4"
|
|
58
|
+
doc-comment = "0.3"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//! SSE Notifications Server Example
|
|
2
|
+
//!
|
|
3
|
+
//! Demonstrates Server-Sent Events support in Spikard matching the AsyncAPI notifications specification.
|
|
4
|
+
|
|
5
|
+
use axum::{Router, routing::get};
|
|
6
|
+
use chrono::Utc;
|
|
7
|
+
use serde::Serialize;
|
|
8
|
+
use serde_json::json;
|
|
9
|
+
use spikard_http::{SseEvent, SseEventProducer, SseState, sse_handler};
|
|
10
|
+
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
11
|
+
use tokio::time::{Duration, sleep};
|
|
12
|
+
use tracing::info;
|
|
13
|
+
|
|
14
|
+
/// Notification event types matching AsyncAPI specification
|
|
15
|
+
#[derive(Debug, Clone, Serialize)]
|
|
16
|
+
#[serde(tag = "type")]
|
|
17
|
+
#[allow(clippy::enum_variant_names)]
|
|
18
|
+
enum Notification {
|
|
19
|
+
#[serde(rename = "system_alert")]
|
|
20
|
+
SystemAlert {
|
|
21
|
+
level: String,
|
|
22
|
+
message: String,
|
|
23
|
+
source: String,
|
|
24
|
+
timestamp: String,
|
|
25
|
+
},
|
|
26
|
+
#[serde(rename = "user_notification")]
|
|
27
|
+
UserNotification {
|
|
28
|
+
#[serde(rename = "userId")]
|
|
29
|
+
user_id: String,
|
|
30
|
+
title: String,
|
|
31
|
+
body: String,
|
|
32
|
+
priority: String,
|
|
33
|
+
timestamp: String,
|
|
34
|
+
},
|
|
35
|
+
#[serde(rename = "status_update")]
|
|
36
|
+
StatusUpdate {
|
|
37
|
+
service: String,
|
|
38
|
+
status: String,
|
|
39
|
+
message: Option<String>,
|
|
40
|
+
metadata: serde_json::Value,
|
|
41
|
+
timestamp: String,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Notification producer implementing SseEventProducer trait
|
|
46
|
+
struct NotificationProducer {
|
|
47
|
+
counter: AtomicUsize,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl NotificationProducer {
|
|
51
|
+
fn new() -> Self {
|
|
52
|
+
Self {
|
|
53
|
+
counter: AtomicUsize::new(0),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn create_notification(&self, index: usize) -> Notification {
|
|
58
|
+
let timestamp = Utc::now().to_rfc3339();
|
|
59
|
+
|
|
60
|
+
match index % 3 {
|
|
61
|
+
0 => Notification::SystemAlert {
|
|
62
|
+
level: "info".to_string(),
|
|
63
|
+
message: format!("System checkpoint {} reached", index),
|
|
64
|
+
source: "monitoring-system".to_string(),
|
|
65
|
+
timestamp,
|
|
66
|
+
},
|
|
67
|
+
1 => Notification::UserNotification {
|
|
68
|
+
user_id: format!("user_{}", index),
|
|
69
|
+
title: "New Update Available".to_string(),
|
|
70
|
+
body: format!("Version 1.{} is now available for download", index),
|
|
71
|
+
priority: "normal".to_string(),
|
|
72
|
+
timestamp,
|
|
73
|
+
},
|
|
74
|
+
_ => Notification::StatusUpdate {
|
|
75
|
+
service: "api-gateway".to_string(),
|
|
76
|
+
status: "operational".to_string(),
|
|
77
|
+
message: Some(format!("Health check {} passed", index)),
|
|
78
|
+
metadata: json!({
|
|
79
|
+
"response_time_ms": 50 + (index % 100),
|
|
80
|
+
"active_connections": 100 + (index % 50)
|
|
81
|
+
}),
|
|
82
|
+
timestamp,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
impl SseEventProducer for NotificationProducer {
|
|
89
|
+
async fn next_event(&self) -> Option<SseEvent> {
|
|
90
|
+
sleep(Duration::from_secs(2)).await;
|
|
91
|
+
|
|
92
|
+
let count = self.counter.fetch_add(1, Ordering::Relaxed);
|
|
93
|
+
|
|
94
|
+
if count >= 10 {
|
|
95
|
+
info!("Completed sending 10 notifications");
|
|
96
|
+
return None;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let notification = self.create_notification(count);
|
|
100
|
+
let event_type = match ¬ification {
|
|
101
|
+
Notification::SystemAlert { .. } => "system_alert",
|
|
102
|
+
Notification::UserNotification { .. } => "user_notification",
|
|
103
|
+
Notification::StatusUpdate { .. } => "status_update",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
info!("Sending notification #{}: {}", count + 1, event_type);
|
|
107
|
+
|
|
108
|
+
let data = serde_json::to_value(notification).unwrap();
|
|
109
|
+
|
|
110
|
+
Some(
|
|
111
|
+
SseEvent::with_type(event_type, data)
|
|
112
|
+
.with_id(format!("event_{}", count))
|
|
113
|
+
.with_retry(3000),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async fn on_connect(&self) {
|
|
118
|
+
info!("Client connected to notifications stream");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async fn on_disconnect(&self) {
|
|
122
|
+
info!("Client disconnected from notifications stream");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[tokio::main]
|
|
127
|
+
async fn main() {
|
|
128
|
+
tracing_subscriber::fmt()
|
|
129
|
+
.with_env_filter("info,sse_notifications=debug")
|
|
130
|
+
.init();
|
|
131
|
+
|
|
132
|
+
let producer = NotificationProducer::new();
|
|
133
|
+
let sse_state = SseState::new(producer);
|
|
134
|
+
|
|
135
|
+
let app = Router::new()
|
|
136
|
+
.route("/notifications", get(sse_handler::<NotificationProducer>))
|
|
137
|
+
.with_state(sse_state);
|
|
138
|
+
|
|
139
|
+
let addr = "127.0.0.1:8000";
|
|
140
|
+
info!("SSE notifications server listening on {}", addr);
|
|
141
|
+
info!("Connect at: http://{}/notifications", addr);
|
|
142
|
+
info!("Try: curl -N http://{}/notifications", addr);
|
|
143
|
+
|
|
144
|
+
let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind");
|
|
145
|
+
|
|
146
|
+
axum::serve(listener, app).await.expect("Server error");
|
|
147
|
+
}
|