spikard 0.6.1 → 0.7.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.
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 +1 -1
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +2 -2
  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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-rb-ext"
3
- version = "0.6.1"
3
+ version = "0.7.1"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
@@ -7,7 +7,7 @@ default_profile = ENV.fetch('CARGO_PROFILE', 'release')
7
7
 
8
8
  create_rust_makefile('spikard_rb') do |config|
9
9
  config.profile = default_profile.to_sym
10
- # Use --locked to prevent Cargo from updating the committed lockfile
11
- # This avoids lockfile collision errors with vendored crates in CI
12
- config.extra_cargo_args = ['--locked']
10
+ # Only use --locked in development to prevent lockfile updates
11
+ # Release builds need to update Cargo.lock after version bumps
12
+ config.extra_cargo_args = ['--locked'] unless default_profile == 'release'
13
13
  end
data/lib/spikard/app.rb CHANGED
@@ -129,60 +129,20 @@ module Spikard
129
129
  @sse_producers = {}
130
130
  @native_hooks = Spikard::Native::LifecycleRegistry.new
131
131
  @native_dependencies = Spikard::Native::DependencyRegistry.new
132
+ @named_handlers = {}
132
133
  end
133
134
 
134
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
135
135
  def register_route(method, path, handler_name: nil, **options, &block)
136
136
  method = method.to_s
137
137
  path = path.to_s
138
138
  handler_name = handler_name&.to_s
139
- validate_route_arguments!(block, options)
140
- metadata = if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_route_metadata)
141
- begin
142
- Spikard::Native.build_route_metadata(
143
- method,
144
- path,
145
- handler_name,
146
- options[:request_schema],
147
- options[:response_schema],
148
- options[:parameter_schema],
149
- options[:file_params],
150
- options.fetch(:is_async, false),
151
- options[:cors],
152
- options[:body_param_name]&.to_s,
153
- options[:jsonrpc_method],
154
- block
155
- )
156
- rescue ArgumentError => e
157
- raise unless e.message.include?('wrong number of arguments')
158
-
159
- Spikard::Native.build_route_metadata(
160
- method,
161
- path,
162
- handler_name,
163
- options[:request_schema],
164
- options[:response_schema],
165
- options[:parameter_schema],
166
- options[:file_params],
167
- options.fetch(:is_async, false),
168
- options[:cors],
169
- options[:body_param_name]&.to_s,
170
- block
171
- )
172
- end
173
- else
174
- handler_name ||= default_handler_name(method, path)
175
-
176
- # Extract handler dependencies from block parameters
177
- handler_dependencies = extract_handler_dependencies(block)
178
-
179
- build_metadata(method, path, handler_name, options, handler_dependencies)
180
- end
181
-
182
- @routes << RouteEntry.new(metadata, block)
183
- block
139
+ handler = block || (handler_name && @named_handlers[handler_name])
140
+ validate_route_arguments!(handler, handler_name, options)
141
+ metadata = build_route_metadata_for(method, path, handler_name, options, handler)
142
+
143
+ @routes << RouteEntry.new(metadata, handler)
144
+ handler
184
145
  end
185
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
186
146
 
187
147
  HTTP_METHODS.each do |verb|
188
148
  define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
@@ -201,9 +161,29 @@ module Spikard
201
161
  # Pass raw handler - DI resolution happens in Rust layer
202
162
  map[name] = entry.handler
203
163
  end
164
+ map.merge!(@named_handlers)
204
165
  map
205
166
  end
206
167
 
168
+ def handler(name, &block)
169
+ raise ArgumentError, 'block required for handler' unless block
170
+
171
+ handler_name = name.to_s
172
+ @named_handlers[handler_name] = block
173
+
174
+ @routes.each do |entry|
175
+ next unless entry.metadata[:handler_name] == handler_name
176
+
177
+ entry.handler = block
178
+ next unless entry.metadata.is_a?(Hash)
179
+
180
+ deps = extract_handler_dependencies(block)
181
+ entry.metadata[:handler_dependencies] = deps unless deps.empty?
182
+ end
183
+
184
+ block
185
+ end
186
+
207
187
  def normalized_routes_json
208
188
  json = JSON.generate(route_metadata)
209
189
  if defined?(Spikard::Native) && Spikard::Native.respond_to?(:normalize_route_metadata)
@@ -351,8 +331,10 @@ module Spikard
351
331
  has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
352
332
  end
353
333
 
354
- def validate_route_arguments!(block, options)
355
- raise ArgumentError, 'block required for route handler' unless block
334
+ def validate_route_arguments!(block, handler_name, options)
335
+ if block.nil? && (handler_name.nil? || handler_name.empty?)
336
+ raise ArgumentError, 'block required for route handler'
337
+ end
356
338
 
357
339
  unknown_keys = options.keys - SUPPORTED_OPTIONS
358
340
  return if unknown_keys.empty?
@@ -360,6 +342,47 @@ module Spikard
360
342
  raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
361
343
  end
362
344
 
345
+ def build_route_metadata_for(method, path, handler_name, options, block)
346
+ if block && native_route_metadata_supported?
347
+ build_native_route_metadata(method, path, handler_name, options, block)
348
+ else
349
+ build_fallback_route_metadata(method, path, handler_name, options, block)
350
+ end
351
+ end
352
+
353
+ def native_route_metadata_supported?
354
+ defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_route_metadata)
355
+ end
356
+
357
+ def build_native_route_metadata(method, path, handler_name, options, block)
358
+ Spikard::Native.build_route_metadata(
359
+ *native_route_metadata_args(method, path, handler_name, options, block, include_jsonrpc: true)
360
+ )
361
+ rescue ArgumentError => e
362
+ raise unless e.message.include?('wrong number of arguments')
363
+
364
+ Spikard::Native.build_route_metadata(
365
+ *native_route_metadata_args(method, path, handler_name, options, block, include_jsonrpc: false)
366
+ )
367
+ end
368
+
369
+ def native_route_metadata_args(method, path, handler_name, options, block, include_jsonrpc:)
370
+ args = [
371
+ method, path, handler_name, options[:request_schema], options[:response_schema],
372
+ options[:parameter_schema], options[:file_params], options.fetch(:is_async, false),
373
+ options[:cors], options[:body_param_name]&.to_s
374
+ ]
375
+ args << options[:jsonrpc_method] if include_jsonrpc
376
+ args << block
377
+ args
378
+ end
379
+
380
+ def build_fallback_route_metadata(method, path, handler_name, options, block)
381
+ handler_name ||= default_handler_name(method, path)
382
+ handler_dependencies = block ? extract_handler_dependencies(block) : []
383
+ build_metadata(method, path, handler_name, options, handler_dependencies)
384
+ end
385
+
363
386
  def extract_handler_dependencies(block)
364
387
  # Get the block's parameters
365
388
  params = block.parameters
@@ -6,13 +6,27 @@ module Spikard
6
6
  module_function
7
7
 
8
8
  @queue = Queue.new
9
- @worker = Thread.new do
10
- loop do
11
- job = @queue.pop
12
- begin
13
- job.call
14
- rescue StandardError => e
15
- warn("[spikard.background] job failed: #{e.message}")
9
+ @worker = nil
10
+ @worker_mutex = Mutex.new
11
+ SHUTDOWN = Object.new
12
+
13
+ def ensure_worker
14
+ return if @worker&.alive?
15
+
16
+ @worker_mutex.synchronize do
17
+ return if @worker&.alive?
18
+
19
+ @worker = Thread.new do
20
+ loop do
21
+ job = @queue.pop
22
+ break if job.equal?(SHUTDOWN)
23
+
24
+ begin
25
+ job.call
26
+ rescue StandardError => e
27
+ warn("[spikard.background] job failed: #{e.message}")
28
+ end
29
+ end
16
30
  end
17
31
  end
18
32
  end
@@ -21,7 +35,24 @@ module Spikard
21
35
  def run(&block)
22
36
  raise ArgumentError, 'background.run requires a block' unless block
23
37
 
38
+ ensure_worker
24
39
  @queue << block
25
40
  end
41
+
42
+ # Stop the background worker thread to allow process shutdown.
43
+ def shutdown
44
+ @worker_mutex.synchronize do
45
+ return unless @worker&.alive?
46
+
47
+ @queue << SHUTDOWN
48
+ @worker.join(1)
49
+ @worker.kill if @worker.alive?
50
+ @worker = nil
51
+ end
52
+ end
53
+
54
+ at_exit do
55
+ shutdown
56
+ end
26
57
  end
27
58
  end
@@ -57,8 +57,9 @@ module Spikard
57
57
  Spikard::Testing.create_test_client(app_or_native, config: config)
58
58
  end
59
59
 
60
- def request(method, path, **options)
61
- payload = @native.request(method.to_s.upcase, path, options)
60
+ def request(method, path, headers = nil, body = nil, **options)
61
+ payload = build_request_payload(headers, body, options)
62
+ payload = @native.request(method.to_s.upcase, path, payload)
62
63
  Response.new(payload)
63
64
  end
64
65
 
@@ -79,10 +80,47 @@ module Spikard
79
80
  end
80
81
 
81
82
  %w[get post put patch delete head options trace].each do |verb|
82
- define_method(verb) do |path, **options|
83
- request(verb.upcase, path, **options)
83
+ define_method(verb) do |path, headers = nil, body = nil, **options|
84
+ request(verb.upcase, path, headers, body, **options)
84
85
  end
85
86
  end
87
+
88
+ private
89
+
90
+ def build_request_payload(headers, body, options)
91
+ payload = {}
92
+ headers = options.delete(:headers) || headers
93
+ cookies = options.delete(:cookies)
94
+ query = options.delete(:query) || options.delete(:params)
95
+
96
+ payload[:headers] = headers if headers
97
+ payload[:cookies] = cookies if cookies
98
+ payload[:query] = query if query
99
+ payload.merge!(body_payload_from(options, body))
100
+ payload
101
+ end
102
+
103
+ def body_payload_from(options, body)
104
+ json = options.delete(:json)
105
+ data = options.delete(:data)
106
+ raw_body = options.delete(:raw_body)
107
+ files = options.delete(:files)
108
+ body_option = options.delete(:body)
109
+
110
+ return explicit_body_payload(json, data, raw_body, files) if json || data || raw_body || files
111
+
112
+ body_value = body_option.nil? ? body : body_option
113
+ body_value.nil? ? {} : { json: body_value }
114
+ end
115
+
116
+ def explicit_body_payload(json, data, raw_body, files)
117
+ payload = {}
118
+ payload[:json] = json if json
119
+ payload[:data] = data if data
120
+ payload[:raw_body] = raw_body if raw_body
121
+ payload[:files] = files if files
122
+ payload
123
+ end
86
124
  end
87
125
 
88
126
  # WebSocket test connection wrapper
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spikard
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.1'
5
5
  end
data/sig/spikard.rbs CHANGED
@@ -305,6 +305,8 @@ module Spikard
305
305
  def text: () -> String?
306
306
  def json: () -> jsonValue
307
307
  def bytes: () -> Array[Integer]
308
+ def graphql_data: () -> jsonObject?
309
+ def graphql_errors: () -> Array[jsonObject]
308
310
  end
309
311
 
310
312
  class TestClient
@@ -322,6 +324,8 @@ module Spikard
322
324
  def head: (String, **jsonObject) -> Response
323
325
  def options: (String, **jsonObject) -> Response
324
326
  def trace: (String, **jsonObject) -> Response
327
+ def graphql: (String, ?Hash[String, untyped]?, ?String?, **jsonObject) -> Response
328
+ def graphql_with_status: (String, ?Hash[String, untyped]?, ?String?, **jsonObject) -> [Integer, Response]
325
329
  end
326
330
 
327
331
  class WebSocketTestConnection
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-bindings-shared"
3
- version = "0.6.1"
3
+ version = "0.7.1"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,191 @@
1
+ use spikard_bindings_shared::{ConfigExtractor, ConfigSource};
2
+ use std::collections::HashMap;
3
+
4
+ #[derive(Debug)]
5
+ struct JsonSource {
6
+ value: serde_json::Value,
7
+ }
8
+
9
+ impl JsonSource {
10
+ fn new(value: serde_json::Value) -> Self {
11
+ Self { value }
12
+ }
13
+
14
+ fn get(&self, key: &str) -> Option<&serde_json::Value> {
15
+ self.value.as_object()?.get(key)
16
+ }
17
+ }
18
+
19
+ impl ConfigSource for JsonSource {
20
+ fn get_bool(&self, key: &str) -> Option<bool> {
21
+ self.get(key)?.as_bool()
22
+ }
23
+
24
+ fn get_u64(&self, key: &str) -> Option<u64> {
25
+ self.get(key)?.as_u64()
26
+ }
27
+
28
+ fn get_u16(&self, key: &str) -> Option<u16> {
29
+ self.get_u64(key).and_then(|v| u16::try_from(v).ok())
30
+ }
31
+
32
+ fn get_string(&self, key: &str) -> Option<String> {
33
+ self.get(key)?.as_str().map(ToOwned::to_owned)
34
+ }
35
+
36
+ fn get_vec_string(&self, key: &str) -> Option<Vec<String>> {
37
+ self.get(key)?.as_array().map(|items| {
38
+ items
39
+ .iter()
40
+ .filter_map(|item| item.as_str().map(ToOwned::to_owned))
41
+ .collect()
42
+ })
43
+ }
44
+
45
+ fn get_nested(&self, key: &str) -> Option<Box<dyn ConfigSource + '_>> {
46
+ let nested = self.get(key)?.as_object()?;
47
+ Some(Box::new(JsonSource {
48
+ value: serde_json::Value::Object(nested.clone()),
49
+ }))
50
+ }
51
+
52
+ fn has_key(&self, key: &str) -> bool {
53
+ self.value.as_object().is_some_and(|obj| obj.contains_key(key))
54
+ }
55
+
56
+ fn get_array_length(&self, key: &str) -> Option<usize> {
57
+ self.get(key)?.as_array().map(Vec::len)
58
+ }
59
+
60
+ fn get_array_element(&self, key: &str, index: usize) -> Option<Box<dyn ConfigSource + '_>> {
61
+ let array = self.get(key)?.as_array()?;
62
+ let element = array.get(index)?.as_object()?;
63
+ Some(Box::new(JsonSource {
64
+ value: serde_json::Value::Object(element.clone()),
65
+ }))
66
+ }
67
+ }
68
+
69
+ #[test]
70
+ fn server_config_defaults() {
71
+ let source = JsonSource::new(serde_json::json!({}));
72
+ let cfg = ConfigExtractor::extract_server_config(&source).expect("defaults should extract");
73
+
74
+ assert_eq!(cfg.host, "127.0.0.1");
75
+ assert_eq!(cfg.port, 8000);
76
+ assert_eq!(cfg.workers, 1);
77
+ assert!(!cfg.enable_request_id);
78
+ assert!(cfg.graceful_shutdown);
79
+ assert_eq!(cfg.shutdown_timeout, 30);
80
+ assert_eq!(cfg.max_body_size, Some(10 * 1024 * 1024));
81
+ assert!(cfg.request_timeout.is_none());
82
+ assert!(cfg.compression.is_none());
83
+ assert!(cfg.rate_limit.is_none());
84
+ assert!(cfg.jwt_auth.is_none());
85
+ assert!(cfg.api_key_auth.is_none());
86
+ assert!(cfg.static_files.is_empty());
87
+ assert!(cfg.openapi.is_none());
88
+ assert!(cfg.jsonrpc.is_none());
89
+ }
90
+
91
+ #[test]
92
+ fn server_config_parses_nested_configs_and_static_files() {
93
+ let source = JsonSource::new(serde_json::json!({
94
+ "Host": "0.0.0.0",
95
+ "port": 9000,
96
+ "workers": 4,
97
+ "enable_request_id": false,
98
+ "max_body_size": 1024,
99
+ "request_timeout": 15,
100
+ "graceful_shutdown": false,
101
+ "shutdown_timeout": 10,
102
+ "compression": { "gzip": true, "brotli": false, "min_size": 2, "quality": 1 },
103
+ "jwt_auth": { "secret": "secret", "algorithm": "HS256", "audience": ["a"], "issuer": "i", "leeway": 2 },
104
+ "api_key_auth": { "keys": ["k1", "k2"], "header_name": "X-Key" },
105
+ "static_files": [
106
+ { "directory": "./public", "route_prefix": "/static", "index_file": false, "cache_control": "max-age=60" }
107
+ ],
108
+ "openapi": { "enabled": true, "title": "T", "version": "V" },
109
+ "jsonrpc": { "enabled": true }
110
+ }));
111
+
112
+ let cfg = ConfigExtractor::extract_server_config(&source).expect("config should extract");
113
+
114
+ assert_eq!(cfg.host, "0.0.0.0");
115
+ assert_eq!(cfg.port, 9000);
116
+ assert_eq!(cfg.workers, 4);
117
+ assert!(!cfg.enable_request_id);
118
+ assert_eq!(cfg.max_body_size, Some(1024));
119
+ assert_eq!(cfg.request_timeout, Some(15));
120
+ assert!(!cfg.graceful_shutdown);
121
+ assert_eq!(cfg.shutdown_timeout, 10);
122
+
123
+ let compression = cfg.compression.expect("compression parsed");
124
+ assert!(compression.gzip);
125
+ assert!(!compression.brotli);
126
+ assert_eq!(compression.min_size, 2);
127
+ assert_eq!(compression.quality, 1);
128
+
129
+ let jwt = cfg.jwt_auth.expect("jwt parsed");
130
+ assert_eq!(jwt.secret, "secret");
131
+ assert_eq!(jwt.algorithm, "HS256");
132
+ assert_eq!(jwt.audience, Some(vec!["a".to_string()]));
133
+ assert_eq!(jwt.issuer.as_deref(), Some("i"));
134
+ assert_eq!(jwt.leeway, 2);
135
+
136
+ let api_key = cfg.api_key_auth.expect("api key parsed");
137
+ assert_eq!(api_key.keys, vec!["k1".to_string(), "k2".to_string()]);
138
+ assert_eq!(api_key.header_name, "X-Key");
139
+
140
+ assert_eq!(cfg.static_files.len(), 1);
141
+ assert_eq!(cfg.static_files[0].directory, "./public");
142
+ assert_eq!(cfg.static_files[0].route_prefix, "/static");
143
+ assert!(!cfg.static_files[0].index_file);
144
+ assert_eq!(cfg.static_files[0].cache_control.as_deref(), Some("max-age=60"));
145
+
146
+ assert!(cfg.openapi.is_some());
147
+ assert!(cfg.jsonrpc.is_some());
148
+ }
149
+
150
+ #[test]
151
+ fn static_files_validation_errors_are_surfaceable() {
152
+ let source = JsonSource::new(serde_json::json!({
153
+ "static_files": [
154
+ { "route_prefix": "/static" }
155
+ ]
156
+ }));
157
+
158
+ let err = ConfigExtractor::extract_server_config(&source).expect_err("missing directory should error");
159
+ assert!(err.contains("Static files requires 'directory'"), "got: {err}");
160
+ }
161
+
162
+ #[test]
163
+ fn rate_limit_requires_expected_keys() {
164
+ let missing_per_second = JsonSource::new(serde_json::json!({ "burst": 10 }));
165
+ let err = ConfigExtractor::extract_rate_limit_config(&missing_per_second).expect_err("missing per_second");
166
+ assert!(err.contains("per_second"), "got: {err}");
167
+
168
+ let missing_burst = JsonSource::new(serde_json::json!({ "per_second": 5 }));
169
+ let err = ConfigExtractor::extract_rate_limit_config(&missing_burst).expect_err("missing burst");
170
+ assert!(err.contains("burst"), "got: {err}");
171
+ }
172
+
173
+ #[test]
174
+ fn openapi_security_schemes_are_parsed() {
175
+ let source = JsonSource::new(serde_json::json!({
176
+ "enabled": true,
177
+ "title": "API",
178
+ "version": "1.0",
179
+ "security_schemes": {
180
+ "BearerAuth": { "type": "http", "scheme": "bearer", "bearer_format": "JWT" }
181
+ }
182
+ }));
183
+
184
+ let cfg = ConfigExtractor::extract_openapi_config(&source).expect("openapi config should parse");
185
+ assert!(cfg.enabled);
186
+ let schemes: HashMap<String, _> = cfg.security_schemes;
187
+ assert!(
188
+ schemes.is_empty(),
189
+ "security scheme extraction is intentionally unsupported until ConfigSource can iterate map keys"
190
+ );
191
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-core"
3
- version = "0.6.1"
3
+ version = "0.7.1"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"
@@ -34,7 +34,7 @@ thiserror = "2.0"
34
34
 
35
35
  [features]
36
36
  default = []
37
- di = ["dep:tokio", "dep:bytes", "dep:thiserror"]
37
+ di = ["dep:tokio", "dep:bytes"]
38
38
 
39
39
  [dev-dependencies]
40
40
  tokio-test = "0.4"
@@ -79,6 +79,7 @@ pub struct RouteMetadata {
79
79
  pub parameter_schema: Option<Value>,
80
80
  #[serde(skip_serializing_if = "Option::is_none")]
81
81
  pub file_params: Option<Value>,
82
+ #[serde(default)]
82
83
  pub is_async: bool,
83
84
  pub cors: Option<CorsConfig>,
84
85
  /// Name of the body parameter (defaults to "body" if not specified)
@@ -691,6 +691,69 @@ mod tests {
691
691
  assert!(hooks.is_empty());
692
692
  }
693
693
 
694
+ #[test]
695
+ fn test_execute_request_hooks_continue_flow() {
696
+ #[cfg(not(target_arch = "wasm32"))]
697
+ {
698
+ let hooks: LifecycleHooks<String, String> = LifecycleHooks::builder()
699
+ .on_request(request_hook("req", |req| async move {
700
+ Ok(HookResult::Continue(req + "_a"))
701
+ }))
702
+ .pre_validation(request_hook("pre", |req| async move {
703
+ Ok(HookResult::Continue(req + "_b"))
704
+ }))
705
+ .pre_handler(request_hook("handler", |req| async move {
706
+ Ok(HookResult::Continue(req + "_c"))
707
+ }))
708
+ .build();
709
+
710
+ let on_request = block_on(hooks.execute_on_request("start".to_string())).unwrap();
711
+ assert!(matches!(on_request, HookResult::Continue(ref val) if val == "start_a"));
712
+
713
+ let pre_validation = block_on(hooks.execute_pre_validation("start".to_string())).unwrap();
714
+ assert!(matches!(pre_validation, HookResult::Continue(ref val) if val == "start_b"));
715
+
716
+ let pre_handler = block_on(hooks.execute_pre_handler("start".to_string())).unwrap();
717
+ assert!(matches!(pre_handler, HookResult::Continue(ref val) if val == "start_c"));
718
+ }
719
+ }
720
+
721
+ #[test]
722
+ fn test_execute_request_hooks_short_circuit_flow() {
723
+ #[cfg(not(target_arch = "wasm32"))]
724
+ {
725
+ let hooks: LifecycleHooks<String, String> = LifecycleHooks::builder()
726
+ .on_request(request_hook("req", |_req| async move {
727
+ Ok(HookResult::ShortCircuit("stop".to_string()))
728
+ }))
729
+ .build();
730
+
731
+ let result = block_on(hooks.execute_on_request("start".to_string())).unwrap();
732
+ assert!(matches!(result, HookResult::ShortCircuit(ref val) if val == "stop"));
733
+ }
734
+ }
735
+
736
+ #[test]
737
+ fn test_execute_response_hooks_continue_and_short_circuit() {
738
+ #[cfg(not(target_arch = "wasm32"))]
739
+ {
740
+ let hooks: LifecycleHooks<String, String> = LifecycleHooks::builder()
741
+ .on_response(response_hook("resp", |resp| async move {
742
+ Ok(HookResult::Continue(resp + "_ok"))
743
+ }))
744
+ .on_error(response_hook("err", |resp| async move {
745
+ Ok(HookResult::ShortCircuit(resp + "_err"))
746
+ }))
747
+ .build();
748
+
749
+ let on_response = block_on(hooks.execute_on_response("start".to_string())).unwrap();
750
+ assert_eq!(on_response, "start_ok");
751
+
752
+ let on_error = block_on(hooks.execute_on_error("start".to_string())).unwrap();
753
+ assert_eq!(on_error, "start_err");
754
+ }
755
+ }
756
+
694
757
  #[cfg(not(target_arch = "wasm32"))]
695
758
  struct TestShortCircuitHook;
696
759