spikard 0.6.2 → 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.
- checksums.yaml +4 -4
- data/README.md +90 -508
- data/ext/spikard_rb/Cargo.lock +3287 -0
- data/ext/spikard_rb/Cargo.toml +1 -1
- data/ext/spikard_rb/extconf.rb +3 -3
- data/lib/spikard/app.rb +72 -49
- data/lib/spikard/background.rb +38 -7
- data/lib/spikard/testing.rb +42 -4
- data/lib/spikard/version.rb +1 -1
- data/sig/spikard.rbs +4 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +1 -1
- data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
- data/vendor/crates/spikard-core/Cargo.toml +1 -1
- data/vendor/crates/spikard-core/src/http.rs +1 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
- data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
- data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
- data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
- data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
- data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
- data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
- data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
- data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
- data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
- data/vendor/crates/spikard-http/Cargo.toml +1 -1
- data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
- data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
- data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
- data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
- data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
- data/vendor/crates/spikard-http/src/testing.rs +171 -0
- data/vendor/crates/spikard-http/src/websocket.rs +79 -6
- data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
- data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
- data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
- data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
- data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
- data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
- data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
- data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
- data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
- data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
- data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
- data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
- data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
- data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
- data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
- data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
- data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
- data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
- data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
- data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
- data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
- data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
- data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
- data/vendor/crates/spikard-rb/Cargo.toml +1 -1
- data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
- data/vendor/crates/spikard-rb/src/handler.rs +12 -9
- data/vendor/crates/spikard-rb/src/lib.rs +137 -124
- data/vendor/crates/spikard-rb/src/request.rs +342 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
- data/vendor/crates/spikard-rb/src/server.rs +1 -8
- data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
- data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
- data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
- data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
- metadata +44 -1
data/ext/spikard_rb/Cargo.toml
CHANGED
data/ext/spikard_rb/extconf.rb
CHANGED
|
@@ -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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
data/lib/spikard/background.rb
CHANGED
|
@@ -6,13 +6,27 @@ module Spikard
|
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
8
|
@queue = Queue.new
|
|
9
|
-
@worker =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
data/lib/spikard/testing.rb
CHANGED
|
@@ -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 =
|
|
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
|
data/lib/spikard/version.rb
CHANGED
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
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|