spikard 0.6.2 → 0.7.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +2 -2
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -0,0 +1,301 @@
1
+ use serde_json::json;
2
+ use spikard_core::parameters::ParameterValidator;
3
+ use std::collections::HashMap;
4
+
5
+ #[test]
6
+ fn parameter_validator_rejects_missing_source() {
7
+ let schema = json!({
8
+ "type": "object",
9
+ "properties": {
10
+ "q": {"type": "string"}
11
+ }
12
+ });
13
+
14
+ let err = ParameterValidator::new(schema).expect_err("missing source should fail");
15
+ assert!(err.contains("missing required 'source' field"), "err: {err}");
16
+ }
17
+
18
+ #[test]
19
+ fn parameter_validator_rejects_invalid_source() {
20
+ let schema = json!({
21
+ "type": "object",
22
+ "properties": {
23
+ "q": {"type": "string", "source": "bogus"}
24
+ }
25
+ });
26
+
27
+ let err = ParameterValidator::new(schema).expect_err("invalid source should fail");
28
+ assert!(err.contains("Invalid source"), "err: {err}");
29
+ }
30
+
31
+ #[test]
32
+ fn optional_field_overrides_required_list() {
33
+ let schema = json!({
34
+ "type": "object",
35
+ "properties": {
36
+ "q": {"type": "string", "source": "query", "optional": true}
37
+ },
38
+ "required": ["q"]
39
+ });
40
+
41
+ let validator = ParameterValidator::new(schema).expect("validator");
42
+ let extracted = validator
43
+ .validate_and_extract(
44
+ &json!({}),
45
+ &HashMap::new(),
46
+ &HashMap::new(),
47
+ &HashMap::new(),
48
+ &HashMap::new(),
49
+ )
50
+ .expect("optional required field should not fail");
51
+
52
+ assert_eq!(extracted, json!({}));
53
+ }
54
+
55
+ #[test]
56
+ fn invalid_uuid_format_yields_uuid_parsing_error() {
57
+ let schema = json!({
58
+ "type": "object",
59
+ "properties": {
60
+ "id": {"type": "string", "format": "uuid", "source": "path"}
61
+ },
62
+ "required": ["id"]
63
+ });
64
+
65
+ let validator = ParameterValidator::new(schema).expect("validator");
66
+
67
+ let mut path_params = HashMap::new();
68
+ path_params.insert("id".to_string(), "g".to_string());
69
+
70
+ let err = validator
71
+ .validate_and_extract(
72
+ &json!({}),
73
+ &HashMap::new(),
74
+ &path_params,
75
+ &HashMap::new(),
76
+ &HashMap::new(),
77
+ )
78
+ .expect_err("invalid uuid should fail");
79
+
80
+ assert_eq!(err.errors.len(), 1);
81
+ assert_eq!(err.errors[0].error_type, "uuid_parsing");
82
+ }
83
+
84
+ #[test]
85
+ fn invalid_duration_format_yields_duration_parsing_error() {
86
+ let schema = json!({
87
+ "type": "object",
88
+ "properties": {
89
+ "d": {"type": "string", "format": "duration", "source": "query"}
90
+ },
91
+ "required": ["d"]
92
+ });
93
+
94
+ let validator = ParameterValidator::new(schema).expect("validator");
95
+
96
+ let mut raw_query = HashMap::new();
97
+ raw_query.insert("d".to_string(), vec!["not-a-duration".to_string()]);
98
+
99
+ let err = validator
100
+ .validate_and_extract(
101
+ &json!({}),
102
+ &raw_query,
103
+ &HashMap::new(),
104
+ &HashMap::new(),
105
+ &HashMap::new(),
106
+ )
107
+ .expect_err("invalid duration should fail");
108
+
109
+ assert_eq!(err.errors.len(), 1);
110
+ assert_eq!(err.errors[0].error_type, "duration_parsing");
111
+ }
112
+
113
+ #[test]
114
+ fn invalid_time_without_timezone_is_rejected() {
115
+ let schema = json!({
116
+ "type": "object",
117
+ "properties": {
118
+ "t": {"type": "string", "format": "time", "source": "query"}
119
+ },
120
+ "required": ["t"]
121
+ });
122
+
123
+ let validator = ParameterValidator::new(schema).expect("validator");
124
+
125
+ let mut raw_query = HashMap::new();
126
+ raw_query.insert("t".to_string(), vec!["10:30:00".to_string()]);
127
+
128
+ let err = validator
129
+ .validate_and_extract(
130
+ &json!({}),
131
+ &raw_query,
132
+ &HashMap::new(),
133
+ &HashMap::new(),
134
+ &HashMap::new(),
135
+ )
136
+ .expect_err("time without timezone should fail");
137
+
138
+ assert_eq!(err.errors.len(), 1);
139
+ assert_eq!(err.errors[0].error_type, "time_parsing");
140
+ }
141
+
142
+ #[test]
143
+ fn required_header_uses_hyphenated_name_in_error_location() {
144
+ let schema = json!({
145
+ "type": "object",
146
+ "properties": {
147
+ "x_api_key": {"type": "string", "source": "header"}
148
+ },
149
+ "required": ["x_api_key"]
150
+ });
151
+
152
+ let validator = ParameterValidator::new(schema).expect("validator");
153
+ let err = validator
154
+ .validate_and_extract(
155
+ &json!({}),
156
+ &HashMap::new(),
157
+ &HashMap::new(),
158
+ &HashMap::new(),
159
+ &HashMap::new(),
160
+ )
161
+ .expect_err("missing header should fail");
162
+
163
+ assert_eq!(err.errors.len(), 1);
164
+ assert_eq!(err.errors[0].error_type, "missing");
165
+ assert_eq!(err.errors[0].loc, vec!["headers".to_string(), "x-api-key".to_string()]);
166
+ }
167
+
168
+ #[test]
169
+ fn required_cookie_is_reported_under_cookie_location() {
170
+ let schema = json!({
171
+ "type": "object",
172
+ "properties": {
173
+ "session_id": {"type": "string", "source": "cookie"}
174
+ },
175
+ "required": ["session_id"]
176
+ });
177
+
178
+ let validator = ParameterValidator::new(schema).expect("validator");
179
+ let err = validator
180
+ .validate_and_extract(
181
+ &json!({}),
182
+ &HashMap::new(),
183
+ &HashMap::new(),
184
+ &HashMap::new(),
185
+ &HashMap::new(),
186
+ )
187
+ .expect_err("missing cookie should fail");
188
+
189
+ assert_eq!(err.errors.len(), 1);
190
+ assert_eq!(err.errors[0].error_type, "missing");
191
+ assert_eq!(err.errors[0].loc, vec!["cookie".to_string(), "session_id".to_string()]);
192
+ }
193
+
194
+ #[test]
195
+ fn boolean_empty_string_is_coerced_to_false() {
196
+ let schema = json!({
197
+ "type": "object",
198
+ "properties": {
199
+ "flag": {"type": "boolean", "source": "query"}
200
+ },
201
+ "required": ["flag"]
202
+ });
203
+
204
+ let validator = ParameterValidator::new(schema).expect("validator");
205
+ let mut raw_query = HashMap::new();
206
+ raw_query.insert("flag".to_string(), vec!["".to_string()]);
207
+
208
+ let extracted = validator
209
+ .validate_and_extract(
210
+ &json!({}),
211
+ &raw_query,
212
+ &HashMap::new(),
213
+ &HashMap::new(),
214
+ &HashMap::new(),
215
+ )
216
+ .expect("empty boolean string should coerce to false");
217
+
218
+ assert_eq!(extracted, json!({"flag": false}));
219
+ }
220
+
221
+ #[test]
222
+ fn array_query_coercion_reports_item_errors() {
223
+ let schema = json!({
224
+ "type": "object",
225
+ "properties": {
226
+ "ids": {"type": "array", "items": {"type": "integer"}, "source": "query"}
227
+ },
228
+ "required": ["ids"]
229
+ });
230
+
231
+ let validator = ParameterValidator::new(schema).expect("validator");
232
+ let mut raw_query = HashMap::new();
233
+ raw_query.insert("ids".to_string(), vec!["1".to_string(), "x".to_string()]);
234
+
235
+ let err = validator
236
+ .validate_and_extract(
237
+ &json!({"ids": ["1", "x"]}),
238
+ &raw_query,
239
+ &HashMap::new(),
240
+ &HashMap::new(),
241
+ &HashMap::new(),
242
+ )
243
+ .expect_err("invalid array item should fail");
244
+
245
+ assert_eq!(err.errors.len(), 1);
246
+ assert_eq!(err.errors[0].error_type, "int_parsing");
247
+ assert_eq!(err.errors[0].loc, vec!["query".to_string(), "ids".to_string()]);
248
+ }
249
+
250
+ #[test]
251
+ fn array_query_coercion_preserves_non_string_items() {
252
+ let schema = json!({
253
+ "type": "object",
254
+ "properties": {
255
+ "ids": {"type": "array", "items": {"type": "integer"}, "source": "query"}
256
+ }
257
+ });
258
+
259
+ let validator = ParameterValidator::new(schema).expect("validator");
260
+
261
+ let extracted = validator
262
+ .validate_and_extract(
263
+ &json!({"ids": [1, "2"]}),
264
+ &HashMap::new(),
265
+ &HashMap::new(),
266
+ &HashMap::new(),
267
+ &HashMap::new(),
268
+ )
269
+ .expect("mixed array should coerce string items and preserve non-strings");
270
+
271
+ assert_eq!(extracted["ids"], json!([1, 2]));
272
+ }
273
+
274
+ #[test]
275
+ fn invalid_time_with_out_of_range_offset_is_rejected() {
276
+ let schema = json!({
277
+ "type": "object",
278
+ "properties": {
279
+ "t": {"type": "string", "format": "time", "source": "query"}
280
+ },
281
+ "required": ["t"]
282
+ });
283
+
284
+ let validator = ParameterValidator::new(schema).expect("validator");
285
+
286
+ let mut raw_query = HashMap::new();
287
+ raw_query.insert("t".to_string(), vec!["10:30:00+24:00".to_string()]);
288
+
289
+ let err = validator
290
+ .validate_and_extract(
291
+ &json!({}),
292
+ &raw_query,
293
+ &HashMap::new(),
294
+ &HashMap::new(),
295
+ &HashMap::new(),
296
+ )
297
+ .expect_err("time offset out of range should fail");
298
+
299
+ assert_eq!(err.errors.len(), 1);
300
+ assert_eq!(err.errors[0].error_type, "time_parsing");
301
+ }
@@ -0,0 +1,67 @@
1
+ use std::collections::HashMap;
2
+ use std::sync::Arc;
3
+
4
+ use serde_json::json;
5
+ use spikard_core::request_data::RequestData;
6
+
7
+ fn make_request_data() -> RequestData {
8
+ RequestData {
9
+ path_params: Arc::new(HashMap::from([("id".to_string(), "42".to_string())])),
10
+ query_params: json!({"page": 3, "filter": "active"}),
11
+ validated_params: None,
12
+ raw_query_params: Arc::new(HashMap::from([
13
+ ("page".to_string(), vec!["3".to_string()]),
14
+ ("filter".to_string(), vec!["active".to_string()]),
15
+ ])),
16
+ body: json!({"name": "spikard", "active": true}),
17
+ #[cfg(feature = "di")]
18
+ raw_body: Some(bytes::Bytes::from_static(b"{\"name\":\"spikard\",\"active\":true}")),
19
+ #[cfg(not(feature = "di"))]
20
+ raw_body: Some(b"{\"name\":\"spikard\",\"active\":true}".to_vec()),
21
+ headers: Arc::new(HashMap::from([
22
+ ("content-type".to_string(), "application/json".to_string()),
23
+ ("x-request-id".to_string(), "req-123".to_string()),
24
+ ])),
25
+ cookies: Arc::new(HashMap::from([("session".to_string(), "abc123".to_string())])),
26
+ method: "POST".to_string(),
27
+ path: "/widgets/42".to_string(),
28
+ #[cfg(feature = "di")]
29
+ dependencies: None,
30
+ }
31
+ }
32
+
33
+ #[test]
34
+ fn request_data_serialization_roundtrip() {
35
+ let original = make_request_data();
36
+
37
+ let json = serde_json::to_string(&original).expect("serialize");
38
+ let decoded: RequestData = serde_json::from_str(&json).expect("deserialize");
39
+
40
+ assert_eq!(original.path_params.as_ref(), decoded.path_params.as_ref());
41
+ assert_eq!(original.query_params, decoded.query_params);
42
+ assert_eq!(original.raw_query_params.as_ref(), decoded.raw_query_params.as_ref());
43
+ assert_eq!(original.body, decoded.body);
44
+ assert_eq!(original.raw_body, decoded.raw_body);
45
+ assert_eq!(original.headers.as_ref(), decoded.headers.as_ref());
46
+ assert_eq!(original.cookies.as_ref(), decoded.cookies.as_ref());
47
+ assert_eq!(original.method, decoded.method);
48
+ assert_eq!(original.path, decoded.path);
49
+ }
50
+
51
+ #[test]
52
+ fn request_data_clone_shares_arc_backing() {
53
+ let request = make_request_data();
54
+
55
+ let initial_path_refs = Arc::strong_count(&request.path_params);
56
+ let initial_headers_refs = Arc::strong_count(&request.headers);
57
+ let initial_cookies_refs = Arc::strong_count(&request.cookies);
58
+ let initial_query_refs = Arc::strong_count(&request.raw_query_params);
59
+
60
+ let clone = request.clone();
61
+
62
+ assert_eq!(Arc::strong_count(&request.path_params), initial_path_refs + 1);
63
+ assert_eq!(Arc::strong_count(&clone.path_params), initial_path_refs + 1);
64
+ assert_eq!(Arc::strong_count(&request.headers), initial_headers_refs + 1);
65
+ assert_eq!(Arc::strong_count(&request.cookies), initial_cookies_refs + 1);
66
+ assert_eq!(Arc::strong_count(&request.raw_query_params), initial_query_refs + 1);
67
+ }
@@ -0,0 +1,250 @@
1
+ use serde_json::json;
2
+ use spikard_core::validation::SchemaValidator;
3
+ use spikard_core::validation::error_mapper::{ErrorCondition, ErrorMapper};
4
+
5
+ #[test]
6
+ fn validator_preprocesses_binary_file_objects_recursively() {
7
+ let schema = json!({
8
+ "type": "object",
9
+ "required": ["file", "files", "nested"],
10
+ "properties": {
11
+ "file": { "type": "string", "format": "binary" },
12
+ "files": {
13
+ "type": "array",
14
+ "items": { "type": "string", "format": "binary" }
15
+ },
16
+ "nested": {
17
+ "type": "object",
18
+ "properties": {
19
+ "inner": { "type": "string", "format": "binary" }
20
+ }
21
+ }
22
+ }
23
+ });
24
+
25
+ let validator = SchemaValidator::new(schema).expect("validator");
26
+
27
+ let file_object = json!({
28
+ "filename": "a.txt",
29
+ "size": 5,
30
+ "content": "hello",
31
+ "content_type": "text/plain"
32
+ });
33
+
34
+ let data = json!({
35
+ "file": file_object.clone(),
36
+ "files": [file_object.clone()],
37
+ "nested": {
38
+ "inner": file_object,
39
+ "other": 1
40
+ }
41
+ });
42
+
43
+ validator
44
+ .validate(&data)
45
+ .expect("binary preprocessing should satisfy schema");
46
+ }
47
+
48
+ #[test]
49
+ fn error_mapper_covers_fallbacks_and_common_conditions() {
50
+ let empty_schema = json!({});
51
+ let prop = "/properties/value";
52
+
53
+ let cases = vec![
54
+ (
55
+ ErrorCondition::StringTooShort { min_length: None },
56
+ "string_too_short",
57
+ "String is too short",
58
+ ),
59
+ (
60
+ ErrorCondition::StringTooLong { max_length: None },
61
+ "string_too_long",
62
+ "String is too long",
63
+ ),
64
+ (
65
+ ErrorCondition::GreaterThan { value: None },
66
+ "greater_than",
67
+ "Input should be greater than the minimum",
68
+ ),
69
+ (
70
+ ErrorCondition::GreaterThanEqual { value: None },
71
+ "greater_than_equal",
72
+ "Input should be greater than or equal to the minimum",
73
+ ),
74
+ (
75
+ ErrorCondition::LessThan { value: None },
76
+ "less_than",
77
+ "Input should be less than the maximum",
78
+ ),
79
+ (
80
+ ErrorCondition::LessThanEqual { value: None },
81
+ "less_than_equal",
82
+ "Input should be less than or equal to the maximum",
83
+ ),
84
+ (
85
+ ErrorCondition::Enum { values: None },
86
+ "enum",
87
+ "Input should be one of the allowed values",
88
+ ),
89
+ (
90
+ ErrorCondition::StringPatternMismatch { pattern: None },
91
+ "string_pattern_mismatch",
92
+ "String does not match expected pattern",
93
+ ),
94
+ ];
95
+
96
+ for (condition, expected_type, expected_msg) in cases {
97
+ let (error_type, msg, ctx) = ErrorMapper::map_error(&condition, &empty_schema, prop, "generic");
98
+ assert_eq!(error_type, expected_type);
99
+ assert_eq!(msg, expected_msg);
100
+ assert!(ctx.is_none());
101
+ }
102
+
103
+ let (error_type, msg, ctx) = ErrorMapper::map_error(
104
+ &ErrorCondition::TypeMismatch {
105
+ expected_type: "integer".to_string(),
106
+ },
107
+ &empty_schema,
108
+ prop,
109
+ "generic",
110
+ );
111
+ assert_eq!(error_type, "int_parsing");
112
+ assert!(msg.contains("valid integer"));
113
+ assert!(ctx.is_none());
114
+
115
+ let (error_type, msg, ctx) = ErrorMapper::map_error(&ErrorCondition::Missing, &empty_schema, prop, "generic");
116
+ assert_eq!(error_type, "missing");
117
+ assert_eq!(msg, "Field required");
118
+ assert!(ctx.is_none());
119
+
120
+ let (error_type, msg, ctx) = ErrorMapper::map_error(
121
+ &ErrorCondition::AdditionalProperties {
122
+ field: "extra".to_string(),
123
+ },
124
+ &empty_schema,
125
+ prop,
126
+ "generic",
127
+ );
128
+ assert_eq!(error_type, "validation_error");
129
+ assert_eq!(msg, "Additional properties are not allowed");
130
+ assert_eq!(ctx.as_ref().unwrap()["unexpected_field"], "extra");
131
+
132
+ let (error_type, msg, ctx) = ErrorMapper::map_error(
133
+ &ErrorCondition::TooFewItems { min_items: Some(2) },
134
+ &empty_schema,
135
+ prop,
136
+ "generic",
137
+ );
138
+ assert_eq!(error_type, "too_short");
139
+ assert!(msg.contains("at least 2"));
140
+ assert_eq!(ctx.as_ref().unwrap()["min_length"], 2);
141
+
142
+ let (error_type, msg, ctx) = ErrorMapper::map_error(&ErrorCondition::TooManyItems, &empty_schema, prop, "generic");
143
+ assert_eq!(error_type, "too_long");
144
+ assert!(msg.contains("at most"));
145
+ assert!(ctx.as_ref().unwrap().get("max_length").is_some());
146
+ }
147
+
148
+ #[test]
149
+ fn error_mapper_uses_schema_constraints_when_present() {
150
+ let schema = json!({
151
+ "type": "object",
152
+ "properties": {
153
+ "value": {
154
+ "type": "string",
155
+ "minLength": 2,
156
+ "maxLength": 4,
157
+ "pattern": "^a+$",
158
+ "enum": ["a", "aa"]
159
+ },
160
+ "num": {
161
+ "type": "integer",
162
+ "exclusiveMinimum": 0,
163
+ "minimum": 1,
164
+ "exclusiveMaximum": 10,
165
+ "maximum": 9
166
+ }
167
+ }
168
+ });
169
+
170
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
171
+ &ErrorCondition::StringTooShort { min_length: None },
172
+ &schema,
173
+ "/properties/value",
174
+ "generic",
175
+ );
176
+ assert_eq!(ty, "string_too_short");
177
+ assert_eq!(ctx.as_ref().unwrap()["min_length"], 2);
178
+
179
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
180
+ &ErrorCondition::StringTooLong { max_length: None },
181
+ &schema,
182
+ "/properties/value",
183
+ "generic",
184
+ );
185
+ assert_eq!(ty, "string_too_long");
186
+ assert_eq!(ctx.as_ref().unwrap()["max_length"], 4);
187
+
188
+ let (ty, msg, ctx) = ErrorMapper::map_error(
189
+ &ErrorCondition::Enum { values: None },
190
+ &schema,
191
+ "/properties/value",
192
+ "generic",
193
+ );
194
+ assert_eq!(ty, "enum");
195
+ assert!(msg.contains("or"));
196
+ assert!(ctx.as_ref().unwrap()["expected"].as_str().unwrap().contains("or"));
197
+
198
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
199
+ &ErrorCondition::StringPatternMismatch { pattern: None },
200
+ &schema,
201
+ "/properties/value",
202
+ "generic",
203
+ );
204
+ assert_eq!(ty, "string_pattern_mismatch");
205
+ assert_eq!(ctx.as_ref().unwrap()["pattern"], "^a+$");
206
+
207
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
208
+ &ErrorCondition::GreaterThan { value: None },
209
+ &schema,
210
+ "/properties/num",
211
+ "generic",
212
+ );
213
+ assert_eq!(ty, "greater_than");
214
+ assert_eq!(ctx.as_ref().unwrap()["gt"], 0);
215
+
216
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
217
+ &ErrorCondition::GreaterThanEqual { value: None },
218
+ &schema,
219
+ "/properties/num",
220
+ "generic",
221
+ );
222
+ assert_eq!(ty, "greater_than_equal");
223
+ assert_eq!(ctx.as_ref().unwrap()["ge"], 1);
224
+
225
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
226
+ &ErrorCondition::LessThan { value: None },
227
+ &schema,
228
+ "/properties/num",
229
+ "generic",
230
+ );
231
+ assert_eq!(ty, "less_than");
232
+ assert_eq!(ctx.as_ref().unwrap()["lt"], 10);
233
+
234
+ let (ty, _msg, ctx) = ErrorMapper::map_error(
235
+ &ErrorCondition::LessThanEqual { value: None },
236
+ &schema,
237
+ "/properties/num",
238
+ "generic",
239
+ );
240
+ assert_eq!(ty, "less_than_equal");
241
+ assert_eq!(ctx.as_ref().unwrap()["le"], 9);
242
+
243
+ let (ty, _msg, ctx) = ErrorMapper::map_error(&ErrorCondition::EmailFormat, &schema, "/properties/value", "generic");
244
+ assert_eq!(ty, "string_pattern_mismatch");
245
+ assert!(ctx.as_ref().unwrap()["pattern"].as_str().unwrap().contains("@"));
246
+
247
+ let (ty, _msg, ctx) = ErrorMapper::map_error(&ErrorCondition::UuidFormat, &schema, "/properties/value", "generic");
248
+ assert_eq!(ty, "uuid_parsing");
249
+ assert!(ctx.is_none());
250
+ }
@@ -0,0 +1,45 @@
1
+ use serde_json::json;
2
+ use spikard_core::validation::SchemaValidator;
3
+
4
+ #[test]
5
+ fn validate_json_reports_parse_errors_as_validation_error_detail() {
6
+ let schema = json!({"type": "object"});
7
+ let validator = SchemaValidator::new(schema).expect("validator");
8
+
9
+ let err = validator.validate_json(b"{").expect_err("invalid json");
10
+ assert_eq!(err.errors.len(), 1);
11
+ assert_eq!(err.errors[0].error_type, "json_parse_error");
12
+ assert_eq!(err.errors[0].loc, vec!["body"]);
13
+ }
14
+
15
+ #[test]
16
+ fn validation_error_locations_include_nested_required_and_additional_properties() {
17
+ let schema = json!({
18
+ "type": "object",
19
+ "required": ["nested"],
20
+ "properties": {
21
+ "nested": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": ["inner"],
25
+ "properties": {
26
+ "inner": {"type": "string", "minLength": 2}
27
+ }
28
+ }
29
+ }
30
+ });
31
+
32
+ let validator = SchemaValidator::new(schema).expect("validator");
33
+
34
+ let missing_inner = json!({ "nested": {} });
35
+ let err = validator.validate(&missing_inner).expect_err("missing inner");
36
+ assert_eq!(err.errors[0].loc, vec!["body", "nested", "inner"]);
37
+
38
+ let extra_prop = json!({ "nested": { "inner": "ok", "extra": true } });
39
+ let err = validator.validate(&extra_prop).expect_err("additional properties");
40
+ assert!(
41
+ err.errors
42
+ .iter()
43
+ .any(|detail| detail.msg.contains("Additional properties"))
44
+ );
45
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-http"
3
- version = "0.6.2"
3
+ version = "0.7.2"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"