spikard 0.4.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -0
  3. data/README.md +659 -0
  4. data/ext/spikard_rb/Cargo.toml +17 -0
  5. data/ext/spikard_rb/extconf.rb +10 -0
  6. data/ext/spikard_rb/src/lib.rs +6 -0
  7. data/lib/spikard/app.rb +405 -0
  8. data/lib/spikard/background.rb +27 -0
  9. data/lib/spikard/config.rb +396 -0
  10. data/lib/spikard/converters.rb +13 -0
  11. data/lib/spikard/handler_wrapper.rb +113 -0
  12. data/lib/spikard/provide.rb +214 -0
  13. data/lib/spikard/response.rb +173 -0
  14. data/lib/spikard/schema.rb +243 -0
  15. data/lib/spikard/sse.rb +111 -0
  16. data/lib/spikard/streaming_response.rb +44 -0
  17. data/lib/spikard/testing.rb +221 -0
  18. data/lib/spikard/upload_file.rb +131 -0
  19. data/lib/spikard/version.rb +5 -0
  20. data/lib/spikard/websocket.rb +59 -0
  21. data/lib/spikard.rb +43 -0
  22. data/sig/spikard.rbs +366 -0
  23. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
  24. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
  25. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  26. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
  27. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
  28. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  29. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  30. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
  31. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
  32. data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
  33. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
  34. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
  35. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
  36. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  37. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
  38. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
  39. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
  40. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
  41. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  42. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  43. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  44. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  45. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  46. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  47. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  48. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  49. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  50. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  51. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  52. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  53. data/vendor/crates/spikard-core/src/errors.rs +39 -0
  54. data/vendor/crates/spikard-core/src/http.rs +153 -0
  55. data/vendor/crates/spikard-core/src/lib.rs +29 -0
  56. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  57. data/vendor/crates/spikard-core/src/metadata.rs +397 -0
  58. data/vendor/crates/spikard-core/src/parameters.rs +723 -0
  59. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  60. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  61. data/vendor/crates/spikard-core/src/router.rs +249 -0
  62. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  63. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  64. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
  65. data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
  66. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  67. data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
  68. data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
  69. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  70. data/vendor/crates/spikard-http/src/background.rs +1562 -0
  71. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  72. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  73. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  74. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  75. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  76. data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
  77. data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
  78. data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
  79. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  80. data/vendor/crates/spikard-http/src/lib.rs +524 -0
  81. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  82. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  83. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  84. data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
  85. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
  86. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  87. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  88. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
  89. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
  90. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
  91. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  92. data/vendor/crates/spikard-http/src/response.rs +399 -0
  93. data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
  94. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  95. data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
  96. data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
  97. data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
  98. data/vendor/crates/spikard-http/src/sse.rs +961 -0
  99. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  100. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  101. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  102. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  103. data/vendor/crates/spikard-http/src/websocket.rs +831 -0
  104. data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
  105. data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
  106. data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
  107. data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
  108. data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
  109. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
  110. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
  111. data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
  112. data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
  113. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
  114. data/vendor/crates/spikard-rb/Cargo.toml +43 -0
  115. data/vendor/crates/spikard-rb/build.rs +199 -0
  116. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  117. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  118. data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
  119. data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
  120. data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
  121. data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
  122. data/vendor/crates/spikard-rb/src/handler.rs +612 -0
  123. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  124. data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
  125. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
  126. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  127. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
  128. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  129. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
  130. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  131. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  132. data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
  133. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  134. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
  135. data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
  136. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  137. data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
  138. 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(&param_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 &notification {
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
+ }