spikard 0.3.6 → 0.5.0
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 +21 -6
- data/ext/spikard_rb/Cargo.toml +2 -2
- data/lib/spikard/app.rb +33 -14
- data/lib/spikard/testing.rb +47 -12
- data/lib/spikard/version.rb +1 -1
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
- data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
- data/vendor/crates/spikard-core/Cargo.toml +4 -4
- data/vendor/crates/spikard-core/src/debug.rs +64 -0
- data/vendor/crates/spikard-core/src/di/container.rs +3 -27
- data/vendor/crates/spikard-core/src/di/factory.rs +1 -5
- data/vendor/crates/spikard-core/src/di/graph.rs +8 -47
- data/vendor/crates/spikard-core/src/di/mod.rs +1 -1
- data/vendor/crates/spikard-core/src/di/resolved.rs +1 -7
- data/vendor/crates/spikard-core/src/di/value.rs +2 -4
- data/vendor/crates/spikard-core/src/errors.rs +30 -0
- data/vendor/crates/spikard-core/src/http.rs +262 -0
- data/vendor/crates/spikard-core/src/lib.rs +1 -1
- data/vendor/crates/spikard-core/src/lifecycle.rs +764 -0
- data/vendor/crates/spikard-core/src/metadata.rs +389 -0
- data/vendor/crates/spikard-core/src/parameters.rs +1962 -159
- data/vendor/crates/spikard-core/src/problem.rs +34 -0
- data/vendor/crates/spikard-core/src/request_data.rs +966 -1
- data/vendor/crates/spikard-core/src/router.rs +263 -2
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +688 -0
- data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +26 -268
- data/vendor/crates/spikard-http/Cargo.toml +12 -16
- data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
- data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
- data/vendor/crates/spikard-http/src/auth.rs +65 -16
- data/vendor/crates/spikard-http/src/background.rs +1614 -3
- data/vendor/crates/spikard-http/src/cors.rs +515 -0
- data/vendor/crates/spikard-http/src/debug.rs +65 -0
- data/vendor/crates/spikard-http/src/di_handler.rs +1322 -77
- data/vendor/crates/spikard-http/src/handler_response.rs +711 -0
- data/vendor/crates/spikard-http/src/handler_trait.rs +607 -5
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +6 -0
- data/vendor/crates/spikard-http/src/lib.rs +33 -28
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +81 -0
- data/vendor/crates/spikard-http/src/lifecycle.rs +765 -0
- data/vendor/crates/spikard-http/src/middleware/mod.rs +372 -117
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +836 -10
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +409 -43
- data/vendor/crates/spikard-http/src/middleware/validation.rs +513 -65
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +345 -0
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1055 -0
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +473 -3
- data/vendor/crates/spikard-http/src/query_parser.rs +455 -31
- data/vendor/crates/spikard-http/src/response.rs +321 -0
- data/vendor/crates/spikard-http/src/server/handler.rs +1572 -9
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +136 -0
- data/vendor/crates/spikard-http/src/server/mod.rs +875 -178
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +674 -23
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +599 -0
- data/vendor/crates/spikard-http/src/sse.rs +983 -21
- data/vendor/crates/spikard-http/src/testing/form.rs +38 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -2
- data/vendor/crates/spikard-http/src/testing.rs +7 -7
- data/vendor/crates/spikard-http/src/websocket.rs +1055 -4
- data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
- data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
- data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
- data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
- data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
- data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
- data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
- data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
- data/vendor/crates/spikard-rb/Cargo.toml +10 -4
- data/vendor/crates/spikard-rb/build.rs +196 -5
- data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +100 -109
- data/vendor/crates/spikard-rb/src/conversion.rs +121 -20
- data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
- data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +12 -46
- data/vendor/crates/spikard-rb/src/handler.rs +100 -107
- data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
- data/vendor/crates/spikard-rb/src/lib.rs +467 -1428
- data/vendor/crates/spikard-rb/src/lifecycle.rs +1 -0
- data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +447 -0
- data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
- data/vendor/crates/spikard-rb/src/server.rs +47 -22
- data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +187 -40
- data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +635 -0
- data/vendor/crates/spikard-rb/src/websocket.rs +178 -37
- metadata +46 -13
- data/vendor/crates/spikard-http/src/parameters.rs +0 -1
- data/vendor/crates/spikard-http/src/problem.rs +0 -1
- data/vendor/crates/spikard-http/src/router.rs +0 -1
- data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
- data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
- data/vendor/crates/spikard-http/src/validation.rs +0 -1
- data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
- /data/vendor/crates/spikard-rb/src/{test_sse.rs → testing/sse.rs} +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#![allow(deprecated)]
|
|
2
|
+
#![deny(clippy::unwrap_used)]
|
|
2
3
|
|
|
3
4
|
//! Spikard Ruby bindings using Magnus FFI.
|
|
4
5
|
//!
|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|
//!
|
|
8
9
|
//! ## Modules
|
|
9
10
|
//!
|
|
10
|
-
//! - `
|
|
11
|
+
//! - `testing`: Testing utilities (client, SSE, WebSocket)
|
|
11
12
|
//! - `handler`: RubyHandler trait implementation
|
|
12
13
|
//! - `di`: Dependency injection bridge for Ruby types
|
|
13
14
|
//! - `config`: ServerConfig extraction from Ruby objects
|
|
@@ -16,26 +17,27 @@
|
|
|
16
17
|
//! - `background`: Background task management
|
|
17
18
|
//! - `lifecycle`: Lifecycle hook implementations
|
|
18
19
|
//! - `sse`: Server-Sent Events support
|
|
19
|
-
//! - `test_sse`: SSE testing utilities
|
|
20
20
|
//! - `websocket`: WebSocket support
|
|
21
|
-
//! - `test_websocket`: WebSocket testing utilities
|
|
22
21
|
|
|
23
22
|
mod background;
|
|
24
23
|
mod config;
|
|
25
24
|
mod conversion;
|
|
26
25
|
mod di;
|
|
26
|
+
mod gvl;
|
|
27
27
|
mod handler;
|
|
28
|
+
mod integration;
|
|
28
29
|
mod lifecycle;
|
|
30
|
+
mod metadata;
|
|
31
|
+
mod request;
|
|
32
|
+
mod runtime;
|
|
29
33
|
mod server;
|
|
30
34
|
mod sse;
|
|
31
|
-
mod
|
|
32
|
-
mod test_sse;
|
|
33
|
-
mod test_websocket;
|
|
35
|
+
mod testing;
|
|
34
36
|
mod websocket;
|
|
35
37
|
|
|
36
38
|
use async_stream::stream;
|
|
37
39
|
use axum::body::Body;
|
|
38
|
-
use axum::http::{HeaderName, HeaderValue, Method,
|
|
40
|
+
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
|
|
39
41
|
use axum_test::{TestServer, TestServerConfig, Transport};
|
|
40
42
|
use bytes::Bytes;
|
|
41
43
|
use cookie::Cookie;
|
|
@@ -44,12 +46,11 @@ use magnus::value::{InnerValue, Opaque};
|
|
|
44
46
|
use magnus::{
|
|
45
47
|
Error, Module, RArray, RHash, RString, Ruby, TryConvert, Value, function, gc::Marker, method, r_hash::ForEach,
|
|
46
48
|
};
|
|
47
|
-
use
|
|
48
|
-
use
|
|
49
|
-
use spikard_http::ParameterValidator;
|
|
50
|
-
use spikard_http::problem::ProblemDetails;
|
|
49
|
+
use serde_json::Value as JsonValue;
|
|
50
|
+
use spikard_http::ProblemDetails;
|
|
51
51
|
use spikard_http::testing::{
|
|
52
|
-
MultipartFilePart, SnapshotError, build_multipart_body, encode_urlencoded_body,
|
|
52
|
+
MultipartFilePart, ResponseSnapshot, SnapshotError, build_multipart_body, encode_urlencoded_body,
|
|
53
|
+
snapshot_response,
|
|
53
54
|
};
|
|
54
55
|
use spikard_http::{Handler, HandlerResponse, HandlerResult, RequestData};
|
|
55
56
|
use spikard_http::{Route, RouteMetadata, SchemaValidator};
|
|
@@ -59,14 +60,15 @@ use std::io;
|
|
|
59
60
|
use std::mem;
|
|
60
61
|
use std::pin::Pin;
|
|
61
62
|
use std::sync::Arc;
|
|
62
|
-
use
|
|
63
|
+
use std::time::Duration;
|
|
64
|
+
use url::Url;
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
66
|
+
use crate::config::extract_server_config;
|
|
67
|
+
use crate::conversion::{extract_files, problem_to_json};
|
|
68
|
+
use crate::integration::build_dependency_container;
|
|
69
|
+
use crate::metadata::{build_route_metadata, ruby_value_to_json};
|
|
70
|
+
use crate::request::NativeRequest;
|
|
71
|
+
use crate::runtime::{normalize_route_metadata, run_server};
|
|
70
72
|
|
|
71
73
|
#[derive(Default)]
|
|
72
74
|
#[magnus::wrap(class = "Spikard::Native::TestClient", free_immediately, mark)]
|
|
@@ -78,8 +80,7 @@ struct ClientInner {
|
|
|
78
80
|
http_server: Arc<TestServer>,
|
|
79
81
|
transport_server: Arc<TestServer>,
|
|
80
82
|
/// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
|
|
81
|
-
|
|
82
|
-
handlers: Vec<RubyHandler>,
|
|
83
|
+
_handlers: Vec<RubyHandler>,
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
struct RequestConfig {
|
|
@@ -107,12 +108,8 @@ struct RubyHandler {
|
|
|
107
108
|
struct RubyHandlerInner {
|
|
108
109
|
handler_proc: Opaque<Value>,
|
|
109
110
|
handler_name: String,
|
|
110
|
-
method: String,
|
|
111
|
-
path: String,
|
|
112
111
|
json_module: Opaque<Value>,
|
|
113
|
-
request_validator: Option<Arc<SchemaValidator>>,
|
|
114
112
|
response_validator: Option<Arc<SchemaValidator>>,
|
|
115
|
-
parameter_validator: Option<ParameterValidator>,
|
|
116
113
|
#[cfg(feature = "di")]
|
|
117
114
|
handler_dependencies: Vec<String>,
|
|
118
115
|
}
|
|
@@ -146,6 +143,7 @@ struct NativeBuiltResponse {
|
|
|
146
143
|
response: RefCell<Option<HandlerResponse>>,
|
|
147
144
|
body_json: Option<JsonValue>,
|
|
148
145
|
/// Ruby values that must be kept alive for GC (e.g., streaming enumerators)
|
|
146
|
+
#[allow(dead_code)]
|
|
149
147
|
gc_handles: Vec<Opaque<Value>>,
|
|
150
148
|
}
|
|
151
149
|
|
|
@@ -159,15 +157,17 @@ struct NativeLifecycleRegistry {
|
|
|
159
157
|
#[magnus::wrap(class = "Spikard::Native::DependencyRegistry", free_immediately, mark)]
|
|
160
158
|
struct NativeDependencyRegistry {
|
|
161
159
|
container: RefCell<Option<spikard_core::di::DependencyContainer>>,
|
|
160
|
+
#[allow(dead_code)]
|
|
162
161
|
gc_handles: RefCell<Vec<Opaque<Value>>>,
|
|
162
|
+
registered_keys: RefCell<Vec<String>>,
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
impl StreamingResponsePayload {
|
|
166
166
|
fn into_response(self) -> Result<HandlerResponse, Error> {
|
|
167
167
|
let ruby = Ruby::get().map_err(|_| {
|
|
168
168
|
Error::new(
|
|
169
|
-
|
|
170
|
-
"Ruby VM unavailable
|
|
169
|
+
magnus::exception::runtime_error(),
|
|
170
|
+
"Ruby VM became unavailable during streaming response construction",
|
|
171
171
|
)
|
|
172
172
|
})?;
|
|
173
173
|
|
|
@@ -221,6 +221,7 @@ impl StreamingResponsePayload {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
impl NativeBuiltResponse {
|
|
224
|
+
#[allow(dead_code)]
|
|
224
225
|
fn new(response: HandlerResponse, body_json: Option<JsonValue>, gc_handles: Vec<Opaque<Value>>) -> Self {
|
|
225
226
|
Self {
|
|
226
227
|
response: RefCell::new(Some(response)),
|
|
@@ -229,7 +230,7 @@ impl NativeBuiltResponse {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
fn
|
|
233
|
+
fn extract_parts(&self) -> Result<(HandlerResponse, Option<JsonValue>), Error> {
|
|
233
234
|
let mut borrow = self.response.borrow_mut();
|
|
234
235
|
let response = borrow
|
|
235
236
|
.take()
|
|
@@ -274,11 +275,8 @@ impl NativeBuiltResponse {
|
|
|
274
275
|
Ok(headers_hash.as_value())
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
#[allow(dead_code)]
|
|
277
279
|
fn mark(&self, marker: &Marker) {
|
|
278
|
-
if self.gc_handles.is_empty() {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
280
|
if let Ok(ruby) = Ruby::get() {
|
|
283
281
|
for handle in &self.gc_handles {
|
|
284
282
|
marker.mark(handle.get_inner_with(&ruby));
|
|
@@ -287,16 +285,6 @@ impl NativeBuiltResponse {
|
|
|
287
285
|
}
|
|
288
286
|
}
|
|
289
287
|
|
|
290
|
-
impl Default for NativeBuiltResponse {
|
|
291
|
-
fn default() -> Self {
|
|
292
|
-
let response = axum::http::Response::builder()
|
|
293
|
-
.status(StatusCode::OK)
|
|
294
|
-
.body(Body::empty())
|
|
295
|
-
.unwrap();
|
|
296
|
-
Self::new(HandlerResponse::from(response), None, Vec::new())
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
288
|
impl NativeLifecycleRegistry {
|
|
301
289
|
fn add_on_request(&self, hook_value: Value) -> Result<(), Error> {
|
|
302
290
|
self.add_hook("on_request", hook_value, |hooks, hook| hooks.add_on_request(hook))
|
|
@@ -324,6 +312,13 @@ impl NativeLifecycleRegistry {
|
|
|
324
312
|
mem::take(&mut *self.hooks.borrow_mut())
|
|
325
313
|
}
|
|
326
314
|
|
|
315
|
+
#[allow(dead_code)]
|
|
316
|
+
fn mark(&self, marker: &Marker) {
|
|
317
|
+
for hook in self.ruby_hooks.borrow().iter() {
|
|
318
|
+
hook.mark(marker);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
327
322
|
fn add_hook<F>(&self, kind: &str, hook_value: Value, push: F) -> Result<(), Error>
|
|
328
323
|
where
|
|
329
324
|
F: Fn(&mut spikard_http::LifecycleHooks, Arc<crate::lifecycle::RubyLifecycleHook>),
|
|
@@ -338,12 +333,6 @@ impl NativeLifecycleRegistry {
|
|
|
338
333
|
self.ruby_hooks.borrow_mut().push(hook);
|
|
339
334
|
Ok(())
|
|
340
335
|
}
|
|
341
|
-
|
|
342
|
-
fn mark(&self, marker: &Marker) {
|
|
343
|
-
for hook in self.ruby_hooks.borrow().iter() {
|
|
344
|
-
hook.mark(marker);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
336
|
}
|
|
348
337
|
|
|
349
338
|
impl Default for NativeDependencyRegistry {
|
|
@@ -351,6 +340,7 @@ impl Default for NativeDependencyRegistry {
|
|
|
351
340
|
Self {
|
|
352
341
|
container: RefCell::new(Some(spikard_core::di::DependencyContainer::new())),
|
|
353
342
|
gc_handles: RefCell::new(Vec::new()),
|
|
343
|
+
registered_keys: RefCell::new(Vec::new()),
|
|
354
344
|
}
|
|
355
345
|
}
|
|
356
346
|
}
|
|
@@ -404,9 +394,20 @@ impl NativeDependencyRegistry {
|
|
|
404
394
|
self.gc_handles.borrow_mut().push(Opaque::from(val));
|
|
405
395
|
}
|
|
406
396
|
|
|
397
|
+
self.registered_keys.borrow_mut().push(key);
|
|
398
|
+
|
|
407
399
|
Ok(())
|
|
408
400
|
}
|
|
409
401
|
|
|
402
|
+
#[allow(dead_code)]
|
|
403
|
+
fn mark(&self, marker: &Marker) {
|
|
404
|
+
if let Ok(ruby) = Ruby::get() {
|
|
405
|
+
for handle in self.gc_handles.borrow().iter() {
|
|
406
|
+
marker.mark(handle.get_inner_with(&ruby));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
410
411
|
fn take_container(&self) -> Result<spikard_core::di::DependencyContainer, Error> {
|
|
411
412
|
let mut borrow = self.container.borrow_mut();
|
|
412
413
|
let container = borrow.take().ok_or_else(|| {
|
|
@@ -417,12 +418,9 @@ impl NativeDependencyRegistry {
|
|
|
417
418
|
})?;
|
|
418
419
|
Ok(container)
|
|
419
420
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
marker.mark(handle.get_inner_with(&ruby));
|
|
424
|
-
}
|
|
425
|
-
}
|
|
421
|
+
|
|
422
|
+
fn keys(&self) -> Vec<String> {
|
|
423
|
+
self.registered_keys.borrow().clone()
|
|
426
424
|
}
|
|
427
425
|
}
|
|
428
426
|
|
|
@@ -492,7 +490,6 @@ impl NativeTestClient {
|
|
|
492
490
|
|
|
493
491
|
let mut server_config = extract_server_config(ruby, config_value)?;
|
|
494
492
|
|
|
495
|
-
// Extract and register dependencies
|
|
496
493
|
#[cfg(feature = "di")]
|
|
497
494
|
{
|
|
498
495
|
if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies) {
|
|
@@ -592,7 +589,8 @@ impl NativeTestClient {
|
|
|
592
589
|
);
|
|
593
590
|
}
|
|
594
591
|
|
|
595
|
-
let
|
|
592
|
+
let runtime = crate::server::global_runtime(ruby)?;
|
|
593
|
+
let http_server = runtime
|
|
596
594
|
.block_on(async { TestServer::new(router.clone()) })
|
|
597
595
|
.map_err(|err| {
|
|
598
596
|
Error::new(
|
|
@@ -605,7 +603,7 @@ impl NativeTestClient {
|
|
|
605
603
|
transport: Some(Transport::HttpRandomPort),
|
|
606
604
|
..Default::default()
|
|
607
605
|
};
|
|
608
|
-
let transport_server =
|
|
606
|
+
let transport_server = runtime
|
|
609
607
|
.block_on(async { TestServer::new_with_config(router, ws_config) })
|
|
610
608
|
.map_err(|err| {
|
|
611
609
|
Error::new(
|
|
@@ -617,7 +615,7 @@ impl NativeTestClient {
|
|
|
617
615
|
*this.inner.borrow_mut() = Some(ClientInner {
|
|
618
616
|
http_server: Arc::new(http_server),
|
|
619
617
|
transport_server: Arc::new(transport_server),
|
|
620
|
-
|
|
618
|
+
_handlers: handler_refs,
|
|
621
619
|
});
|
|
622
620
|
|
|
623
621
|
Ok(())
|
|
@@ -638,7 +636,8 @@ impl NativeTestClient {
|
|
|
638
636
|
|
|
639
637
|
let request_config = parse_request_config(ruby, options)?;
|
|
640
638
|
|
|
641
|
-
let
|
|
639
|
+
let runtime = crate::server::global_runtime(ruby)?;
|
|
640
|
+
let response = runtime
|
|
642
641
|
.block_on(execute_request(
|
|
643
642
|
inner.http_server.clone(),
|
|
644
643
|
http_method,
|
|
@@ -670,16 +669,31 @@ impl NativeTestClient {
|
|
|
670
669
|
|
|
671
670
|
drop(inner_borrow);
|
|
672
671
|
|
|
673
|
-
let
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
672
|
+
let timeout_duration = websocket_timeout();
|
|
673
|
+
let ws = crate::call_without_gvl!(
|
|
674
|
+
block_on_websocket_connect,
|
|
675
|
+
args: (
|
|
676
|
+
server, Arc<TestServer>,
|
|
677
|
+
path, String,
|
|
678
|
+
timeout_duration, Duration
|
|
679
|
+
),
|
|
680
|
+
return_type: Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError>
|
|
681
|
+
)
|
|
682
|
+
.map_err(|err| match err {
|
|
683
|
+
WebSocketConnectError::Timeout => Error::new(
|
|
684
|
+
ruby.exception_runtime_error(),
|
|
685
|
+
format!(
|
|
686
|
+
"WebSocket connect timed out after {}ms",
|
|
687
|
+
timeout_duration.as_millis()
|
|
688
|
+
),
|
|
689
|
+
),
|
|
690
|
+
WebSocketConnectError::Other(message) => Error::new(
|
|
691
|
+
ruby.exception_runtime_error(),
|
|
692
|
+
format!("WebSocket connect failed: {}", message),
|
|
693
|
+
),
|
|
680
694
|
})?;
|
|
681
695
|
|
|
682
|
-
let ws_conn =
|
|
696
|
+
let ws_conn = testing::websocket::WebSocketTestConnection::new(ws);
|
|
683
697
|
Ok(ruby.obj_wrap(ws_conn).as_value())
|
|
684
698
|
}
|
|
685
699
|
|
|
@@ -689,18 +703,74 @@ impl NativeTestClient {
|
|
|
689
703
|
.as_ref()
|
|
690
704
|
.ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
|
|
691
705
|
|
|
692
|
-
let
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
706
|
+
let runtime = crate::server::global_runtime(ruby)?;
|
|
707
|
+
let request_config = RequestConfig {
|
|
708
|
+
query: None,
|
|
709
|
+
headers: HashMap::new(),
|
|
710
|
+
cookies: HashMap::new(),
|
|
711
|
+
body: None,
|
|
712
|
+
};
|
|
713
|
+
let response = runtime
|
|
714
|
+
.block_on(execute_request(
|
|
715
|
+
inner.http_server.clone(),
|
|
716
|
+
Method::GET,
|
|
717
|
+
path.clone(),
|
|
718
|
+
request_config,
|
|
719
|
+
))
|
|
720
|
+
.map_err(|err| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", err.0)))?;
|
|
721
|
+
|
|
722
|
+
let body = response.body_text.unwrap_or_default().into_bytes();
|
|
723
|
+
let snapshot = ResponseSnapshot {
|
|
724
|
+
status: response.status,
|
|
725
|
+
headers: response.headers,
|
|
726
|
+
body,
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
testing::sse::sse_stream_from_response(ruby, &snapshot)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fn websocket_timeout() -> Duration {
|
|
734
|
+
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
|
|
735
|
+
let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
|
|
736
|
+
.ok()
|
|
737
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
738
|
+
.unwrap_or(DEFAULT_TIMEOUT_MS);
|
|
739
|
+
Duration::from_millis(timeout_ms)
|
|
740
|
+
}
|
|
698
741
|
|
|
699
|
-
|
|
742
|
+
#[derive(Debug)]
|
|
743
|
+
enum WebSocketConnectError {
|
|
744
|
+
Timeout,
|
|
745
|
+
Other(String),
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
fn block_on_websocket_connect(
|
|
749
|
+
server: Arc<TestServer>,
|
|
750
|
+
path: String,
|
|
751
|
+
timeout_duration: Duration,
|
|
752
|
+
) -> Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError> {
|
|
753
|
+
let url = server
|
|
754
|
+
.server_url(&path)
|
|
755
|
+
.map_err(|err| WebSocketConnectError::Other(err.to_string()))?;
|
|
756
|
+
let ws_url = to_ws_url(url)?;
|
|
757
|
+
|
|
758
|
+
match crate::testing::websocket::WebSocketConnection::connect(ws_url, timeout_duration) {
|
|
759
|
+
Ok(ws) => Ok(ws),
|
|
760
|
+
Err(crate::testing::websocket::WebSocketIoError::Timeout) => Err(WebSocketConnectError::Timeout),
|
|
761
|
+
Err(err) => Err(WebSocketConnectError::Other(format!("{:?}", err))),
|
|
700
762
|
}
|
|
701
763
|
}
|
|
702
764
|
|
|
703
|
-
|
|
765
|
+
fn to_ws_url(mut url: Url) -> Result<Url, WebSocketConnectError> {
|
|
766
|
+
let scheme = match url.scheme() {
|
|
767
|
+
"https" => "wss",
|
|
768
|
+
_ => "ws",
|
|
769
|
+
};
|
|
770
|
+
url.set_scheme(scheme)
|
|
771
|
+
.map_err(|_| WebSocketConnectError::Other("Failed to set WebSocket scheme".to_string()))?;
|
|
772
|
+
Ok(url)
|
|
773
|
+
}
|
|
704
774
|
|
|
705
775
|
impl RubyHandler {
|
|
706
776
|
fn new(route: &Route, handler_value: Value, json_module: Value) -> Result<Self, Error> {
|
|
@@ -708,12 +778,8 @@ impl RubyHandler {
|
|
|
708
778
|
inner: Arc::new(RubyHandlerInner {
|
|
709
779
|
handler_proc: Opaque::from(handler_value),
|
|
710
780
|
handler_name: route.handler_name.clone(),
|
|
711
|
-
method: route.method.as_str().to_string(),
|
|
712
|
-
path: route.path.clone(),
|
|
713
781
|
json_module: Opaque::from(json_module),
|
|
714
|
-
request_validator: route.request_validator.clone(),
|
|
715
782
|
response_validator: route.response_validator.clone(),
|
|
716
|
-
parameter_validator: route.parameter_validator.clone(),
|
|
717
783
|
#[cfg(feature = "di")]
|
|
718
784
|
handler_dependencies: route.handler_dependencies.clone(),
|
|
719
785
|
}),
|
|
@@ -727,8 +793,6 @@ impl RubyHandler {
|
|
|
727
793
|
_ruby: &Ruby,
|
|
728
794
|
handler_value: Value,
|
|
729
795
|
handler_name: String,
|
|
730
|
-
method: String,
|
|
731
|
-
path: String,
|
|
732
796
|
json_module: Value,
|
|
733
797
|
route: &Route,
|
|
734
798
|
) -> Result<Self, Error> {
|
|
@@ -736,12 +800,8 @@ impl RubyHandler {
|
|
|
736
800
|
inner: Arc::new(RubyHandlerInner {
|
|
737
801
|
handler_proc: Opaque::from(handler_value),
|
|
738
802
|
handler_name,
|
|
739
|
-
method,
|
|
740
|
-
path,
|
|
741
803
|
json_module: Opaque::from(json_module),
|
|
742
|
-
request_validator: route.request_validator.clone(),
|
|
743
804
|
response_validator: route.response_validator.clone(),
|
|
744
|
-
parameter_validator: route.parameter_validator.clone(),
|
|
745
805
|
#[cfg(feature = "di")]
|
|
746
806
|
handler_dependencies: route.handler_dependencies.clone(),
|
|
747
807
|
}),
|
|
@@ -758,31 +818,7 @@ impl RubyHandler {
|
|
|
758
818
|
}
|
|
759
819
|
|
|
760
820
|
fn handle(&self, request_data: RequestData) -> HandlerResult {
|
|
761
|
-
|
|
762
|
-
&& let Err(errors) = validator.validate(&request_data.body)
|
|
763
|
-
{
|
|
764
|
-
let problem = ProblemDetails::from_validation_error(&errors);
|
|
765
|
-
let error_json = problem_to_json(&problem);
|
|
766
|
-
return Err((problem.status_code(), error_json));
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
let validated_params = if let Some(validator) = &self.inner.parameter_validator {
|
|
770
|
-
match validator.validate_and_extract(
|
|
771
|
-
&request_data.query_params,
|
|
772
|
-
request_data.raw_query_params.as_ref(),
|
|
773
|
-
request_data.path_params.as_ref(),
|
|
774
|
-
request_data.headers.as_ref(),
|
|
775
|
-
request_data.cookies.as_ref(),
|
|
776
|
-
) {
|
|
777
|
-
Ok(value) => Some(value),
|
|
778
|
-
Err(errors) => {
|
|
779
|
-
let problem = ProblemDetails::from_validation_error(&errors);
|
|
780
|
-
return Err((problem.status_code(), problem_to_json(&problem)));
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
} else {
|
|
784
|
-
None
|
|
785
|
-
};
|
|
821
|
+
let validated_params = request_data.validated_params.clone();
|
|
786
822
|
|
|
787
823
|
let ruby = Ruby::get().map_err(|_| {
|
|
788
824
|
(
|
|
@@ -791,25 +827,21 @@ impl RubyHandler {
|
|
|
791
827
|
)
|
|
792
828
|
})?;
|
|
793
829
|
|
|
794
|
-
|
|
830
|
+
#[cfg(feature = "di")]
|
|
831
|
+
let dependencies = request_data.dependencies.clone();
|
|
832
|
+
|
|
833
|
+
let request_value = build_ruby_request(&ruby, request_data, validated_params)
|
|
795
834
|
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
|
796
835
|
|
|
797
836
|
let handler_proc = self.inner.handler_proc.get_inner_with(&ruby);
|
|
798
837
|
|
|
799
|
-
// Extract resolved dependencies (if any) and convert to Ruby keyword arguments
|
|
800
838
|
#[cfg(feature = "di")]
|
|
801
839
|
let handler_result = {
|
|
802
|
-
if let Some(deps) = &
|
|
803
|
-
// Build keyword arguments hash from dependencies
|
|
804
|
-
// ONLY include dependencies that the handler actually declared
|
|
840
|
+
if let Some(deps) = &dependencies {
|
|
805
841
|
let kwargs_hash = ruby.hash_new();
|
|
806
842
|
|
|
807
|
-
// Check if all required handler dependencies are present
|
|
808
|
-
// If any are missing, return error BEFORE calling handler
|
|
809
843
|
for key in &self.inner.handler_dependencies {
|
|
810
844
|
if !deps.contains(key) {
|
|
811
|
-
// Handler requires a dependency that was not resolved
|
|
812
|
-
// This should have been caught by DI system, but safety check here
|
|
813
845
|
return Err((
|
|
814
846
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
815
847
|
format!(
|
|
@@ -820,17 +852,11 @@ impl RubyHandler {
|
|
|
820
852
|
}
|
|
821
853
|
}
|
|
822
854
|
|
|
823
|
-
// Filter dependencies: only pass those declared by the handler
|
|
824
855
|
for key in &self.inner.handler_dependencies {
|
|
825
856
|
if let Some(value) = deps.get_arc(key) {
|
|
826
|
-
// Check what type of dependency this is and extract Ruby value
|
|
827
857
|
let ruby_val = if let Some(wrapper) = value.downcast_ref::<crate::di::RubyValueWrapper>() {
|
|
828
|
-
// It's a Ruby value wrapper (singleton with preserved mutations)
|
|
829
|
-
// Get the raw Ruby value directly to preserve object identity
|
|
830
858
|
wrapper.get_value(&ruby)
|
|
831
859
|
} else if let Some(json) = value.downcast_ref::<serde_json::Value>() {
|
|
832
|
-
// It's already JSON (non-singleton or value dependency)
|
|
833
|
-
// Convert JSON to Ruby value
|
|
834
860
|
match crate::di::json_to_ruby(&ruby, json) {
|
|
835
861
|
Ok(val) => val,
|
|
836
862
|
Err(e) => {
|
|
@@ -850,7 +876,6 @@ impl RubyHandler {
|
|
|
850
876
|
));
|
|
851
877
|
};
|
|
852
878
|
|
|
853
|
-
// Add to kwargs hash
|
|
854
879
|
let key_sym = ruby.to_symbol(key);
|
|
855
880
|
if let Err(e) = kwargs_hash.aset(key_sym, ruby_val) {
|
|
856
881
|
return Err((
|
|
@@ -861,13 +886,6 @@ impl RubyHandler {
|
|
|
861
886
|
}
|
|
862
887
|
}
|
|
863
888
|
|
|
864
|
-
// Call handler with request and dependencies as keyword arguments
|
|
865
|
-
// Ruby 3.x requires keyword arguments to be passed differently than Ruby 2.x
|
|
866
|
-
// We'll create a Ruby lambda that calls the handler with ** splat operator
|
|
867
|
-
//
|
|
868
|
-
// Equivalent Ruby code:
|
|
869
|
-
// lambda { |req, kwargs| handler_proc.call(req, **kwargs) }.call(request, kwargs_hash)
|
|
870
|
-
|
|
871
889
|
let wrapper_code = ruby
|
|
872
890
|
.eval::<Value>(
|
|
873
891
|
r#"
|
|
@@ -885,7 +903,6 @@ impl RubyHandler {
|
|
|
885
903
|
|
|
886
904
|
wrapper_code.funcall("call", (handler_proc, request_value, kwargs_hash))
|
|
887
905
|
} else {
|
|
888
|
-
// No dependencies, call with just request
|
|
889
906
|
handler_proc.funcall("call", (request_value,))
|
|
890
907
|
}
|
|
891
908
|
};
|
|
@@ -1137,10 +1154,15 @@ fn parse_request_config(ruby: &Ruby, options: Value) -> Result<RequestConfig, Er
|
|
|
1137
1154
|
};
|
|
1138
1155
|
|
|
1139
1156
|
let files_opt = get_kw(ruby, hash, "files");
|
|
1140
|
-
let has_files = files_opt.
|
|
1157
|
+
let has_files = files_opt.as_ref().is_some_and(|f| !f.is_nil());
|
|
1141
1158
|
|
|
1142
1159
|
let body = if has_files {
|
|
1143
|
-
let files_value = files_opt.
|
|
1160
|
+
let files_value = files_opt.ok_or_else(|| {
|
|
1161
|
+
Error::new(
|
|
1162
|
+
ruby.exception_runtime_error(),
|
|
1163
|
+
"Files option should be Some if has_files is true",
|
|
1164
|
+
)
|
|
1165
|
+
})?;
|
|
1144
1166
|
let files = extract_files(ruby, files_value)?;
|
|
1145
1167
|
|
|
1146
1168
|
let mut form_data = Vec::new();
|
|
@@ -1204,65 +1226,12 @@ fn parse_request_config(ruby: &Ruby, options: Value) -> Result<RequestConfig, Er
|
|
|
1204
1226
|
|
|
1205
1227
|
fn build_ruby_request(
|
|
1206
1228
|
ruby: &Ruby,
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
validated_params: Option<&JsonValue>,
|
|
1229
|
+
request_data: RequestData,
|
|
1230
|
+
validated_params: Option<JsonValue>,
|
|
1210
1231
|
) -> Result<Value, Error> {
|
|
1211
|
-
let
|
|
1212
|
-
|
|
1213
|
-
hash.aset(ruby.intern("method"), ruby.str_new(&handler.method))?;
|
|
1214
|
-
hash.aset(ruby.intern("path"), ruby.str_new(&handler.path))?;
|
|
1215
|
-
|
|
1216
|
-
let path_params = map_to_ruby_hash(ruby, request_data.path_params.as_ref())?;
|
|
1217
|
-
hash.aset(ruby.intern("path_params"), path_params)?;
|
|
1232
|
+
let native_request = NativeRequest::from_request_data(request_data, validated_params);
|
|
1218
1233
|
|
|
1219
|
-
|
|
1220
|
-
hash.aset(ruby.intern("query"), query_value)?;
|
|
1221
|
-
|
|
1222
|
-
let raw_query = multimap_to_ruby_hash(ruby, request_data.raw_query_params.as_ref())?;
|
|
1223
|
-
hash.aset(ruby.intern("raw_query"), raw_query)?;
|
|
1224
|
-
|
|
1225
|
-
let headers = map_to_ruby_hash(ruby, request_data.headers.as_ref())?;
|
|
1226
|
-
hash.aset(ruby.intern("headers"), headers)?;
|
|
1227
|
-
|
|
1228
|
-
let cookies = map_to_ruby_hash(ruby, request_data.cookies.as_ref())?;
|
|
1229
|
-
hash.aset(ruby.intern("cookies"), cookies)?;
|
|
1230
|
-
|
|
1231
|
-
let body_value = json_to_ruby(ruby, &request_data.body)?;
|
|
1232
|
-
hash.aset(ruby.intern("body"), body_value)?;
|
|
1233
|
-
|
|
1234
|
-
let params_value = if let Some(validated) = validated_params {
|
|
1235
|
-
json_to_ruby(ruby, validated)?
|
|
1236
|
-
} else {
|
|
1237
|
-
build_default_params(ruby, request_data)?
|
|
1238
|
-
};
|
|
1239
|
-
hash.aset(ruby.intern("params"), params_value)?;
|
|
1240
|
-
|
|
1241
|
-
Ok(hash.as_value())
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
fn build_default_params(ruby: &Ruby, request_data: &RequestData) -> Result<Value, Error> {
|
|
1245
|
-
let mut map = JsonMap::new();
|
|
1246
|
-
|
|
1247
|
-
for (key, value) in request_data.path_params.as_ref() {
|
|
1248
|
-
map.insert(key.clone(), JsonValue::String(value.clone()));
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if let JsonValue::Object(obj) = &request_data.query_params {
|
|
1252
|
-
for (key, value) in obj {
|
|
1253
|
-
map.insert(key.clone(), value.clone());
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
for (key, value) in request_data.headers.as_ref() {
|
|
1258
|
-
map.insert(key.clone(), JsonValue::String(value.clone()));
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
for (key, value) in request_data.cookies.as_ref() {
|
|
1262
|
-
map.insert(key.clone(), JsonValue::String(value.clone()));
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
json_to_ruby(ruby, &JsonValue::Object(map))
|
|
1234
|
+
Ok(ruby.obj_wrap(native_request).as_value())
|
|
1266
1235
|
}
|
|
1267
1236
|
|
|
1268
1237
|
fn interpret_handler_response(
|
|
@@ -1270,16 +1239,15 @@ fn interpret_handler_response(
|
|
|
1270
1239
|
handler: &RubyHandlerInner,
|
|
1271
1240
|
value: Value,
|
|
1272
1241
|
) -> Result<RubyHandlerResult, Error> {
|
|
1273
|
-
// Prefer native-built responses to avoid Ruby-side normalization overhead
|
|
1274
1242
|
let native_method = ruby.intern("to_native_response");
|
|
1275
1243
|
if value.respond_to(native_method, false)? {
|
|
1276
1244
|
let native_value: Value = value.funcall("to_native_response", ())?;
|
|
1277
1245
|
if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(native_value) {
|
|
1278
|
-
let (response, body_json) = native_resp.
|
|
1246
|
+
let (response, body_json) = native_resp.extract_parts()?;
|
|
1279
1247
|
return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
|
|
1280
1248
|
}
|
|
1281
1249
|
} else if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(value) {
|
|
1282
|
-
let (response, body_json) = native_resp.
|
|
1250
|
+
let (response, body_json) = native_resp.extract_parts()?;
|
|
1283
1251
|
return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
|
|
1284
1252
|
}
|
|
1285
1253
|
|
|
@@ -1384,6 +1352,7 @@ fn value_to_string_map(ruby: &Ruby, value: Value) -> Result<HashMap<String, Stri
|
|
|
1384
1352
|
})
|
|
1385
1353
|
}
|
|
1386
1354
|
|
|
1355
|
+
#[allow(dead_code)]
|
|
1387
1356
|
fn header_pairs_from_map(headers: HashMap<String, String>) -> Result<Vec<(HeaderName, HeaderValue)>, Error> {
|
|
1388
1357
|
let ruby = Ruby::get().map_err(|err| Error::new(magnus::exception::runtime_error(), err.to_string()))?;
|
|
1389
1358
|
headers
|
|
@@ -1438,1334 +1407,404 @@ fn response_to_ruby(ruby: &Ruby, response: TestResponseData) -> Result<Value, Er
|
|
|
1438
1407
|
Ok(hash.as_value())
|
|
1439
1408
|
}
|
|
1440
1409
|
|
|
1441
|
-
fn
|
|
1442
|
-
|
|
1443
|
-
|
|
1410
|
+
fn get_kw(ruby: &Ruby, hash: RHash, name: &str) -> Option<Value> {
|
|
1411
|
+
let sym = ruby.intern(name);
|
|
1412
|
+
hash.get(sym).or_else(|| hash.get(name))
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
|
|
1416
|
+
let symbol_key = ruby.intern(name);
|
|
1417
|
+
if let Some(value) = handlers.get(symbol_key) {
|
|
1418
|
+
return Ok(value);
|
|
1444
1419
|
}
|
|
1445
1420
|
|
|
1446
|
-
let
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1421
|
+
let string_key = ruby.str_new(name);
|
|
1422
|
+
if let Some(value) = handlers.get(string_key) {
|
|
1423
|
+
return Ok(value);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
Err(Error::new(
|
|
1427
|
+
ruby.exception_name_error(),
|
|
1428
|
+
format!("Handler '{name}' not provided"),
|
|
1429
|
+
))
|
|
1453
1430
|
}
|
|
1454
1431
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
}),
|
|
1463
|
-
JsonValue::Number(num) => {
|
|
1464
|
-
if let Some(i) = num.as_i64() {
|
|
1465
|
-
Ok(ruby.integer_from_i64(i).as_value())
|
|
1466
|
-
} else if let Some(f) = num.as_f64() {
|
|
1467
|
-
Ok(ruby.float_from_f64(f).as_value())
|
|
1468
|
-
} else {
|
|
1469
|
-
Ok(ruby.qnil().as_value())
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
JsonValue::String(str_val) => Ok(ruby.str_new(str_val).as_value()),
|
|
1473
|
-
JsonValue::Array(items) => {
|
|
1474
|
-
let array = ruby.ary_new();
|
|
1475
|
-
for item in items {
|
|
1476
|
-
array.push(json_to_ruby(ruby, item)?)?;
|
|
1477
|
-
}
|
|
1478
|
-
Ok(array.as_value())
|
|
1479
|
-
}
|
|
1480
|
-
JsonValue::Object(map) => {
|
|
1481
|
-
let hash = ruby.hash_new();
|
|
1482
|
-
for (key, item) in map {
|
|
1483
|
-
hash.aset(ruby.str_new(key), json_to_ruby(ruby, item)?)?;
|
|
1484
|
-
}
|
|
1485
|
-
Ok(hash.as_value())
|
|
1432
|
+
/// GC mark hook so Ruby keeps handler closures alive.
|
|
1433
|
+
#[allow(dead_code)]
|
|
1434
|
+
fn mark(client: &NativeTestClient, marker: &Marker) {
|
|
1435
|
+
let inner_ref = client.inner.borrow();
|
|
1436
|
+
if let Some(inner) = inner_ref.as_ref() {
|
|
1437
|
+
for handler in &inner._handlers {
|
|
1438
|
+
handler.mark(marker);
|
|
1486
1439
|
}
|
|
1487
1440
|
}
|
|
1488
1441
|
}
|
|
1489
1442
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1443
|
+
/// Return the Spikard version.
|
|
1444
|
+
fn version() -> String {
|
|
1445
|
+
env!("CARGO_PKG_VERSION").to_string()
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/// Build a native response from content, status code, and headers.
|
|
1449
|
+
///
|
|
1450
|
+
/// Called by `Spikard::Response` to construct native response objects.
|
|
1451
|
+
/// The content can be a String (raw body), Hash/Array (JSON), or nil.
|
|
1452
|
+
fn build_response(ruby: &Ruby, content: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
|
|
1497
1453
|
let status_u16 = u16::try_from(status_code)
|
|
1498
1454
|
.map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
|
|
1499
1455
|
|
|
1500
|
-
let
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
.
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
let json_module = ruby
|
|
1508
|
-
.class_object()
|
|
1509
|
-
.const_get("JSON")
|
|
1510
|
-
.map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
|
|
1456
|
+
let header_map = if headers.is_nil() {
|
|
1457
|
+
HashMap::new()
|
|
1458
|
+
} else {
|
|
1459
|
+
let hash = RHash::try_convert(headers)?;
|
|
1460
|
+
hash.to_hash_map::<String, String>()?
|
|
1461
|
+
};
|
|
1511
1462
|
|
|
1512
|
-
let
|
|
1513
|
-
|
|
1514
|
-
Vec::new()
|
|
1463
|
+
let (body_json, raw_body_opt) = if content.is_nil() {
|
|
1464
|
+
(None, None)
|
|
1515
1465
|
} else if let Ok(str_value) = RString::try_convert(content) {
|
|
1516
1466
|
let slice = unsafe { str_value.as_slice() };
|
|
1517
|
-
slice.to_vec()
|
|
1467
|
+
(None, Some(slice.to_vec()))
|
|
1518
1468
|
} else {
|
|
1519
|
-
let
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
Error::new(
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
)
|
|
1526
|
-
})?
|
|
1469
|
+
let json_module = ruby
|
|
1470
|
+
.class_object()
|
|
1471
|
+
.const_get("JSON")
|
|
1472
|
+
.map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
|
|
1473
|
+
let json_value = ruby_value_to_json(ruby, json_module, content)?;
|
|
1474
|
+
(Some(json_value), None)
|
|
1527
1475
|
};
|
|
1528
1476
|
|
|
1529
|
-
let
|
|
1477
|
+
let status = StatusCode::from_u16(status_u16).map_err(|err| {
|
|
1478
|
+
Error::new(
|
|
1479
|
+
ruby.exception_arg_error(),
|
|
1480
|
+
format!("Invalid status code {}: {}", status_u16, err),
|
|
1481
|
+
)
|
|
1482
|
+
})?;
|
|
1530
1483
|
|
|
1531
|
-
|
|
1532
|
-
response_builder = response_builder.header(name, value);
|
|
1533
|
-
}
|
|
1484
|
+
let mut response_builder = axum::http::Response::builder().status(status);
|
|
1534
1485
|
|
|
1535
|
-
|
|
1536
|
-
let
|
|
1486
|
+
for (name, value) in &header_map {
|
|
1487
|
+
let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
|
|
1537
1488
|
Error::new(
|
|
1538
1489
|
ruby.exception_arg_error(),
|
|
1539
|
-
format!("Invalid
|
|
1490
|
+
format!("Invalid header name '{}': {}", name, err),
|
|
1540
1491
|
)
|
|
1541
1492
|
})?;
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
);
|
|
1493
|
+
let header_value = HeaderValue::from_str(value).map_err(|err| {
|
|
1494
|
+
Error::new(
|
|
1495
|
+
ruby.exception_arg_error(),
|
|
1496
|
+
format!("Invalid header value for '{}': {}", name, err),
|
|
1497
|
+
)
|
|
1498
|
+
})?;
|
|
1499
|
+
response_builder = response_builder.header(header_name, header_value);
|
|
1549
1500
|
}
|
|
1550
1501
|
|
|
1551
|
-
let
|
|
1502
|
+
let body_bytes = if let Some(raw) = raw_body_opt {
|
|
1503
|
+
raw
|
|
1504
|
+
} else if let Some(json_value) = body_json.as_ref() {
|
|
1505
|
+
serde_json::to_vec(&json_value).map_err(|err| {
|
|
1506
|
+
Error::new(
|
|
1507
|
+
ruby.exception_runtime_error(),
|
|
1508
|
+
format!("Failed to serialise response body: {}", err),
|
|
1509
|
+
)
|
|
1510
|
+
})?
|
|
1511
|
+
} else {
|
|
1512
|
+
Vec::new()
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
let axum_response = response_builder.body(Body::from(body_bytes)).map_err(|err| {
|
|
1552
1516
|
Error::new(
|
|
1553
1517
|
ruby.exception_runtime_error(),
|
|
1554
|
-
format!("Failed to build response: {
|
|
1518
|
+
format!("Failed to build response: {}", err),
|
|
1555
1519
|
)
|
|
1556
1520
|
})?;
|
|
1557
1521
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
Vec::new(),
|
|
1562
|
-
))
|
|
1522
|
+
let handler_response = HandlerResponse::Response(axum_response);
|
|
1523
|
+
let native_response = NativeBuiltResponse::new(handler_response, body_json.clone(), Vec::new());
|
|
1524
|
+
Ok(ruby.obj_wrap(native_response).as_value())
|
|
1563
1525
|
}
|
|
1564
1526
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
) -> Result<NativeBuiltResponse, Error> {
|
|
1527
|
+
/// Build a native streaming response from stream, status code, and headers.
|
|
1528
|
+
///
|
|
1529
|
+
/// Called by `Spikard::StreamingResponse` to construct native response objects.
|
|
1530
|
+
/// The stream must be an enumerator that responds to #next.
|
|
1531
|
+
fn build_streaming_response(ruby: &Ruby, stream: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
|
|
1571
1532
|
let status_u16 = u16::try_from(status_code)
|
|
1572
1533
|
.map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
|
|
1573
1534
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
)
|
|
1535
|
+
let header_map = if headers.is_nil() {
|
|
1536
|
+
HashMap::new()
|
|
1537
|
+
} else {
|
|
1538
|
+
let hash = RHash::try_convert(headers)?;
|
|
1539
|
+
hash.to_hash_map::<String, String>()?
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
let next_method = ruby.intern("next");
|
|
1543
|
+
if !stream.respond_to(next_method, false)? {
|
|
1544
|
+
return Err(Error::new(ruby.exception_arg_error(), "stream must respond to #next"));
|
|
1579
1545
|
}
|
|
1580
1546
|
|
|
1581
|
-
let
|
|
1582
|
-
|
|
1583
|
-
let payload = StreamingResponsePayload {
|
|
1584
|
-
enumerator: enumerator.clone(),
|
|
1547
|
+
let streaming_payload = StreamingResponsePayload {
|
|
1548
|
+
enumerator: Arc::new(Opaque::from(stream)),
|
|
1585
1549
|
status: status_u16,
|
|
1586
|
-
headers,
|
|
1550
|
+
headers: header_map,
|
|
1587
1551
|
};
|
|
1588
1552
|
|
|
1589
|
-
let
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
None,
|
|
1593
|
-
vec![(*enumerator).clone()],
|
|
1594
|
-
))
|
|
1553
|
+
let response = streaming_payload.into_response()?;
|
|
1554
|
+
let native_response = NativeBuiltResponse::new(response, None, vec![Opaque::from(stream)]);
|
|
1555
|
+
Ok(ruby.obj_wrap(native_response).as_value())
|
|
1595
1556
|
}
|
|
1596
1557
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1558
|
+
#[magnus::init]
|
|
1559
|
+
pub fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
1560
|
+
let spikard = ruby.define_module("Spikard")?;
|
|
1561
|
+
spikard.define_singleton_method("version", function!(version, 0))?;
|
|
1562
|
+
let native = match spikard.const_get("Native") {
|
|
1563
|
+
Ok(module) => module,
|
|
1564
|
+
Err(_) => spikard.define_module("Native")?,
|
|
1565
|
+
};
|
|
1604
1566
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
}
|
|
1612
|
-
hash.aset(ruby.str_new(key), array)?;
|
|
1613
|
-
}
|
|
1614
|
-
Ok(hash.as_value())
|
|
1615
|
-
}
|
|
1567
|
+
native.define_singleton_method("run_server", function!(run_server, 7))?;
|
|
1568
|
+
native.define_singleton_method("normalize_route_metadata", function!(normalize_route_metadata, 1))?;
|
|
1569
|
+
native.define_singleton_method("background_run", function!(background::background_run, 1))?;
|
|
1570
|
+
native.define_singleton_method("build_route_metadata", function!(build_route_metadata, 12))?;
|
|
1571
|
+
native.define_singleton_method("build_response", function!(build_response, 3))?;
|
|
1572
|
+
native.define_singleton_method("build_streaming_response", function!(build_streaming_response, 3))?;
|
|
1616
1573
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1574
|
+
let class = native.define_class("TestClient", ruby.class_object())?;
|
|
1575
|
+
class.define_alloc_func::<NativeTestClient>();
|
|
1576
|
+
class.define_method("initialize", method!(NativeTestClient::initialize, 6))?;
|
|
1577
|
+
class.define_method("request", method!(NativeTestClient::request, 3))?;
|
|
1578
|
+
class.define_method("websocket", method!(NativeTestClient::websocket, 1))?;
|
|
1579
|
+
class.define_method("sse", method!(NativeTestClient::sse, 1))?;
|
|
1580
|
+
class.define_method("close", method!(NativeTestClient::close, 0))?;
|
|
1622
1581
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
if let Some(stripped) = segment.strip_prefix(':') {
|
|
1627
|
-
format!("{{{}}}", stripped)
|
|
1628
|
-
} else {
|
|
1629
|
-
segment.to_string()
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1582
|
+
let built_response_class = native.define_class("BuiltResponse", ruby.class_object())?;
|
|
1583
|
+
built_response_class.define_method("status_code", method!(NativeBuiltResponse::status_code, 0))?;
|
|
1584
|
+
built_response_class.define_method("headers", method!(NativeBuiltResponse::headers, 0))?;
|
|
1632
1585
|
|
|
1633
|
-
let
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1586
|
+
let request_class = native.define_class("Request", ruby.class_object())?;
|
|
1587
|
+
request_class.define_method("method", method!(NativeRequest::method, 0))?;
|
|
1588
|
+
request_class.define_method("path", method!(NativeRequest::path, 0))?;
|
|
1589
|
+
request_class.define_method("path_params", method!(NativeRequest::path_params, 0))?;
|
|
1590
|
+
request_class.define_method("query", method!(NativeRequest::query, 0))?;
|
|
1591
|
+
request_class.define_method("raw_query", method!(NativeRequest::raw_query, 0))?;
|
|
1592
|
+
request_class.define_method("headers", method!(NativeRequest::headers, 0))?;
|
|
1593
|
+
request_class.define_method("cookies", method!(NativeRequest::cookies, 0))?;
|
|
1594
|
+
request_class.define_method("body", method!(NativeRequest::body, 0))?;
|
|
1595
|
+
request_class.define_method("raw_body", method!(NativeRequest::raw_body, 0))?;
|
|
1596
|
+
request_class.define_method("params", method!(NativeRequest::params, 0))?;
|
|
1597
|
+
request_class.define_method("to_h", method!(NativeRequest::to_h, 0))?;
|
|
1598
|
+
request_class.define_method("[]", method!(NativeRequest::index, 1))?;
|
|
1640
1599
|
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1600
|
+
let lifecycle_registry_class = native.define_class("LifecycleRegistry", ruby.class_object())?;
|
|
1601
|
+
lifecycle_registry_class.define_alloc_func::<NativeLifecycleRegistry>();
|
|
1602
|
+
lifecycle_registry_class.define_method("add_on_request", method!(NativeLifecycleRegistry::add_on_request, 1))?;
|
|
1603
|
+
lifecycle_registry_class.define_method(
|
|
1604
|
+
"pre_validation",
|
|
1605
|
+
method!(NativeLifecycleRegistry::add_pre_validation, 1),
|
|
1606
|
+
)?;
|
|
1607
|
+
lifecycle_registry_class.define_method("pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
|
|
1608
|
+
lifecycle_registry_class.define_method("on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
|
|
1609
|
+
lifecycle_registry_class.define_method("on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
|
|
1650
1610
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
let mut dependencies = Vec::new();
|
|
1660
|
-
for i in 0..params.len() {
|
|
1661
|
-
let entry: Value = params.entry(i as isize)?;
|
|
1662
|
-
if let Some(pair) = RArray::from_value(entry) {
|
|
1663
|
-
if pair.len() < 2 {
|
|
1664
|
-
continue;
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
let kind_val: Value = pair.entry(0)?;
|
|
1668
|
-
let name_val: Value = pair.entry(1)?;
|
|
1669
|
-
|
|
1670
|
-
let kind_symbol: magnus::Symbol = magnus::Symbol::try_convert(kind_val)?;
|
|
1671
|
-
let kind_name = kind_symbol.name().unwrap_or_default();
|
|
1611
|
+
let dependency_registry_class = native.define_class("DependencyRegistry", ruby.class_object())?;
|
|
1612
|
+
dependency_registry_class.define_alloc_func::<NativeDependencyRegistry>();
|
|
1613
|
+
dependency_registry_class.define_method("register_value", method!(NativeDependencyRegistry::register_value, 2))?;
|
|
1614
|
+
dependency_registry_class.define_method(
|
|
1615
|
+
"register_factory",
|
|
1616
|
+
method!(NativeDependencyRegistry::register_factory, 5),
|
|
1617
|
+
)?;
|
|
1618
|
+
dependency_registry_class.define_method("keys", method!(NativeDependencyRegistry::keys, 0))?;
|
|
1672
1619
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1620
|
+
let spikard_module = ruby.define_module("Spikard")?;
|
|
1621
|
+
testing::websocket::init(ruby, &spikard_module)?;
|
|
1622
|
+
testing::sse::init(ruby, &spikard_module)?;
|
|
1678
1623
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1624
|
+
let _ = NativeBuiltResponse::mark as fn(&NativeBuiltResponse, &Marker);
|
|
1625
|
+
let _ = NativeLifecycleRegistry::mark as fn(&NativeLifecycleRegistry, &Marker);
|
|
1626
|
+
let _ = NativeDependencyRegistry::mark as fn(&NativeDependencyRegistry, &Marker);
|
|
1627
|
+
let _ = NativeRequest::mark as fn(&NativeRequest, &Marker);
|
|
1628
|
+
let _ = RubyHandler::mark as fn(&RubyHandler, &Marker);
|
|
1629
|
+
let _ = mark as fn(&NativeTestClient, &Marker);
|
|
1681
1630
|
|
|
1682
|
-
|
|
1683
|
-
if let Some(json) = value {
|
|
1684
|
-
json_to_ruby(ruby, json)
|
|
1685
|
-
} else {
|
|
1686
|
-
Ok(ruby.qnil().as_value())
|
|
1687
|
-
}
|
|
1631
|
+
Ok(())
|
|
1688
1632
|
}
|
|
1689
1633
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
let origins = cors_config
|
|
1694
|
-
.allowed_origins
|
|
1695
|
-
.iter()
|
|
1696
|
-
.map(|s| JsonValue::String(s.clone()))
|
|
1697
|
-
.collect();
|
|
1698
|
-
hash.aset(
|
|
1699
|
-
ruby.to_symbol("allowed_origins"),
|
|
1700
|
-
json_to_ruby(ruby, &JsonValue::Array(origins))?,
|
|
1701
|
-
)?;
|
|
1702
|
-
let methods = cors_config
|
|
1703
|
-
.allowed_methods
|
|
1704
|
-
.iter()
|
|
1705
|
-
.map(|s| JsonValue::String(s.clone()))
|
|
1706
|
-
.collect();
|
|
1707
|
-
hash.aset(
|
|
1708
|
-
ruby.to_symbol("allowed_methods"),
|
|
1709
|
-
json_to_ruby(ruby, &JsonValue::Array(methods))?,
|
|
1710
|
-
)?;
|
|
1711
|
-
|
|
1712
|
-
if !cors_config.allowed_headers.is_empty() {
|
|
1713
|
-
let headers = cors_config
|
|
1714
|
-
.allowed_headers
|
|
1715
|
-
.iter()
|
|
1716
|
-
.map(|s| JsonValue::String(s.clone()))
|
|
1717
|
-
.collect();
|
|
1718
|
-
hash.aset(
|
|
1719
|
-
ruby.to_symbol("allowed_headers"),
|
|
1720
|
-
json_to_ruby(ruby, &JsonValue::Array(headers))?,
|
|
1721
|
-
)?;
|
|
1722
|
-
}
|
|
1634
|
+
#[cfg(test)]
|
|
1635
|
+
mod tests {
|
|
1636
|
+
use serde_json::json;
|
|
1723
1637
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
json_to_ruby(ruby, &JsonValue::Array(exposed))?,
|
|
1729
|
-
)?;
|
|
1730
|
-
}
|
|
1638
|
+
/// Test that NativeBuiltResponse can extract parts safely
|
|
1639
|
+
#[test]
|
|
1640
|
+
fn test_native_built_response_status_extraction() {
|
|
1641
|
+
use axum::http::StatusCode;
|
|
1731
1642
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1643
|
+
let valid_codes = vec![200u16, 201, 204, 301, 400, 404, 500, 503];
|
|
1644
|
+
for code in valid_codes {
|
|
1645
|
+
let status = StatusCode::from_u16(code);
|
|
1646
|
+
assert!(status.is_ok(), "Status code {} should be valid", code);
|
|
1734
1647
|
}
|
|
1648
|
+
}
|
|
1735
1649
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
ruby.qfalse().as_value()
|
|
1741
|
-
};
|
|
1742
|
-
hash.aset(ruby.to_symbol("allow_credentials"), bool_value)?;
|
|
1743
|
-
}
|
|
1650
|
+
/// Test that invalid status codes are rejected
|
|
1651
|
+
#[test]
|
|
1652
|
+
fn test_native_built_response_invalid_status() {
|
|
1653
|
+
use axum::http::StatusCode;
|
|
1744
1654
|
|
|
1745
|
-
|
|
1746
|
-
} else {
|
|
1747
|
-
Ok(ruby.qnil().as_value())
|
|
1655
|
+
assert!(StatusCode::from_u16(599).is_ok(), "599 should be valid");
|
|
1748
1656
|
}
|
|
1749
|
-
}
|
|
1750
1657
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1658
|
+
/// Test HeaderName/HeaderValue construction
|
|
1659
|
+
#[test]
|
|
1660
|
+
fn test_header_construction() {
|
|
1661
|
+
use axum::http::{HeaderName, HeaderValue};
|
|
1753
1662
|
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
ruby.qfalse().as_value()
|
|
1761
|
-
};
|
|
1762
|
-
hash.aset(ruby.to_symbol("is_async"), is_async_val)?;
|
|
1663
|
+
let valid_headers = vec![
|
|
1664
|
+
("X-Custom-Header", "value"),
|
|
1665
|
+
("Content-Type", "application/json"),
|
|
1666
|
+
("Cache-Control", "no-cache"),
|
|
1667
|
+
("Accept", "*/*"),
|
|
1668
|
+
];
|
|
1763
1669
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
)?;
|
|
1768
|
-
hash.aset(
|
|
1769
|
-
ruby.to_symbol("response_schema"),
|
|
1770
|
-
option_json_to_ruby(ruby, &metadata.response_schema)?,
|
|
1771
|
-
)?;
|
|
1772
|
-
hash.aset(
|
|
1773
|
-
ruby.to_symbol("parameter_schema"),
|
|
1774
|
-
option_json_to_ruby(ruby, &metadata.parameter_schema)?,
|
|
1775
|
-
)?;
|
|
1776
|
-
hash.aset(
|
|
1777
|
-
ruby.to_symbol("file_params"),
|
|
1778
|
-
option_json_to_ruby(ruby, &metadata.file_params)?,
|
|
1779
|
-
)?;
|
|
1780
|
-
hash.aset(
|
|
1781
|
-
ruby.to_symbol("body_param_name"),
|
|
1782
|
-
metadata
|
|
1783
|
-
.body_param_name
|
|
1784
|
-
.as_ref()
|
|
1785
|
-
.map(|s| ruby.str_new(s).as_value())
|
|
1786
|
-
.unwrap_or_else(|| ruby.qnil().as_value()),
|
|
1787
|
-
)?;
|
|
1788
|
-
|
|
1789
|
-
hash.aset(ruby.to_symbol("cors"), cors_to_ruby(ruby, &metadata.cors)?)?;
|
|
1670
|
+
for (name, value) in valid_headers {
|
|
1671
|
+
let header_name = HeaderName::from_bytes(name.as_bytes());
|
|
1672
|
+
let header_value = HeaderValue::from_str(value);
|
|
1790
1673
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
if let Some(deps) = &metadata.handler_dependencies {
|
|
1794
|
-
let array = ruby.ary_new();
|
|
1795
|
-
for dep in deps {
|
|
1796
|
-
array.push(ruby.str_new(dep))?;
|
|
1797
|
-
}
|
|
1798
|
-
hash.aset(ruby.to_symbol("handler_dependencies"), array)?;
|
|
1799
|
-
} else {
|
|
1800
|
-
hash.aset(ruby.to_symbol("handler_dependencies"), ruby.qnil())?;
|
|
1674
|
+
assert!(header_name.is_ok(), "Header name '{}' should be valid", name);
|
|
1675
|
+
assert!(header_value.is_ok(), "Header value '{}' should be valid", value);
|
|
1801
1676
|
}
|
|
1802
1677
|
}
|
|
1803
1678
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
if value.is_nil() {
|
|
1809
|
-
return Ok(None);
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
let hash = RHash::try_convert(value)?;
|
|
1679
|
+
/// Test invalid headers are rejected
|
|
1680
|
+
#[test]
|
|
1681
|
+
fn test_invalid_header_construction() {
|
|
1682
|
+
use axum::http::{HeaderName, HeaderValue};
|
|
1813
1683
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
.get(ruby.to_symbol("allowed_methods"))
|
|
1820
|
-
.and_then(|v| Vec::<String>::try_convert(v).ok())
|
|
1821
|
-
.unwrap_or_default();
|
|
1822
|
-
let allowed_headers = hash
|
|
1823
|
-
.get(ruby.to_symbol("allowed_headers"))
|
|
1824
|
-
.and_then(|v| Vec::<String>::try_convert(v).ok())
|
|
1825
|
-
.unwrap_or_default();
|
|
1826
|
-
let expose_headers = hash
|
|
1827
|
-
.get(ruby.to_symbol("expose_headers"))
|
|
1828
|
-
.and_then(|v| Vec::<String>::try_convert(v).ok());
|
|
1829
|
-
let max_age = hash
|
|
1830
|
-
.get(ruby.to_symbol("max_age"))
|
|
1831
|
-
.and_then(|v| i64::try_convert(v).ok())
|
|
1832
|
-
.map(|v| v as u32);
|
|
1833
|
-
let allow_credentials = hash
|
|
1834
|
-
.get(ruby.to_symbol("allow_credentials"))
|
|
1835
|
-
.and_then(|v| bool::try_convert(v).ok());
|
|
1836
|
-
|
|
1837
|
-
Ok(Some(spikard_http::CorsConfig {
|
|
1838
|
-
allowed_origins,
|
|
1839
|
-
allowed_methods,
|
|
1840
|
-
allowed_headers,
|
|
1841
|
-
expose_headers,
|
|
1842
|
-
max_age,
|
|
1843
|
-
allow_credentials,
|
|
1844
|
-
}))
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
#[allow(clippy::too_many_arguments)]
|
|
1848
|
-
fn build_route_metadata(
|
|
1849
|
-
ruby: &Ruby,
|
|
1850
|
-
method: String,
|
|
1851
|
-
path: String,
|
|
1852
|
-
handler_name: Option<String>,
|
|
1853
|
-
request_schema_value: Value,
|
|
1854
|
-
response_schema_value: Value,
|
|
1855
|
-
parameter_schema_value: Value,
|
|
1856
|
-
file_params_value: Value,
|
|
1857
|
-
is_async: bool,
|
|
1858
|
-
cors_value: Value,
|
|
1859
|
-
body_param_name: Option<String>,
|
|
1860
|
-
handler_value: Value,
|
|
1861
|
-
) -> Result<Value, Error> {
|
|
1862
|
-
let normalized_path = normalize_path_for_route(&path);
|
|
1863
|
-
let final_handler_name = handler_name.unwrap_or_else(|| default_handler_name(&method, &normalized_path));
|
|
1864
|
-
|
|
1865
|
-
let json_module = ruby
|
|
1866
|
-
.class_object()
|
|
1867
|
-
.const_get("JSON")
|
|
1868
|
-
.map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
|
|
1869
|
-
|
|
1870
|
-
let request_schema = if request_schema_value.is_nil() {
|
|
1871
|
-
None
|
|
1872
|
-
} else {
|
|
1873
|
-
Some(ruby_value_to_json(ruby, json_module, request_schema_value)?)
|
|
1874
|
-
};
|
|
1875
|
-
let response_schema = if response_schema_value.is_nil() {
|
|
1876
|
-
None
|
|
1877
|
-
} else {
|
|
1878
|
-
Some(ruby_value_to_json(ruby, json_module, response_schema_value)?)
|
|
1879
|
-
};
|
|
1880
|
-
let parameter_schema = if parameter_schema_value.is_nil() {
|
|
1881
|
-
None
|
|
1882
|
-
} else {
|
|
1883
|
-
Some(ruby_value_to_json(ruby, json_module, parameter_schema_value)?)
|
|
1884
|
-
};
|
|
1885
|
-
let file_params = if file_params_value.is_nil() {
|
|
1886
|
-
None
|
|
1887
|
-
} else {
|
|
1888
|
-
Some(ruby_value_to_json(ruby, json_module, file_params_value)?)
|
|
1889
|
-
};
|
|
1890
|
-
|
|
1891
|
-
let cors = parse_cors_config(ruby, cors_value)?;
|
|
1892
|
-
let handler_dependencies = extract_handler_dependencies_from_ruby(ruby, handler_value)?;
|
|
1893
|
-
|
|
1894
|
-
#[cfg(feature = "di")]
|
|
1895
|
-
let handler_deps_option = if handler_dependencies.is_empty() {
|
|
1896
|
-
None
|
|
1897
|
-
} else {
|
|
1898
|
-
Some(handler_dependencies.clone())
|
|
1899
|
-
};
|
|
1900
|
-
|
|
1901
|
-
let mut metadata = RouteMetadata {
|
|
1902
|
-
method,
|
|
1903
|
-
path: normalized_path,
|
|
1904
|
-
handler_name: final_handler_name,
|
|
1905
|
-
request_schema,
|
|
1906
|
-
response_schema,
|
|
1907
|
-
parameter_schema,
|
|
1908
|
-
file_params,
|
|
1909
|
-
is_async,
|
|
1910
|
-
cors,
|
|
1911
|
-
body_param_name,
|
|
1912
|
-
#[cfg(feature = "di")]
|
|
1913
|
-
handler_dependencies: handler_deps_option,
|
|
1914
|
-
};
|
|
1915
|
-
|
|
1916
|
-
// Validate schemas and parameter validator during build to fail fast
|
|
1917
|
-
let registry = spikard_http::SchemaRegistry::new();
|
|
1918
|
-
let route = Route::from_metadata(metadata.clone(), ®istry).map_err(|err| {
|
|
1919
|
-
Error::new(
|
|
1920
|
-
ruby.exception_runtime_error(),
|
|
1921
|
-
format!("Failed to build route metadata: {err}"),
|
|
1922
|
-
)
|
|
1923
|
-
})?;
|
|
1684
|
+
let invalid_name = "X\nInvalid";
|
|
1685
|
+
assert!(
|
|
1686
|
+
HeaderName::from_bytes(invalid_name.as_bytes()).is_err(),
|
|
1687
|
+
"Header with newline should be invalid"
|
|
1688
|
+
);
|
|
1924
1689
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1690
|
+
let invalid_value = "value\x00invalid";
|
|
1691
|
+
assert!(
|
|
1692
|
+
HeaderValue::from_str(invalid_value).is_err(),
|
|
1693
|
+
"Header with null byte should be invalid"
|
|
1694
|
+
);
|
|
1927
1695
|
}
|
|
1928
1696
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1697
|
+
/// Test JSON serialization for responses
|
|
1698
|
+
#[test]
|
|
1699
|
+
fn test_json_response_serialization() {
|
|
1700
|
+
let json_obj = json!({
|
|
1701
|
+
"status": "success",
|
|
1702
|
+
"data": [1, 2, 3],
|
|
1703
|
+
"nested": {
|
|
1704
|
+
"key": "value"
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1931
1707
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
hash.get(sym).or_else(|| hash.get(name))
|
|
1935
|
-
}
|
|
1708
|
+
let serialized = serde_json::to_vec(&json_obj);
|
|
1709
|
+
assert!(serialized.is_ok(), "JSON should serialize");
|
|
1936
1710
|
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
if let Some(value) = handlers.get(symbol_key) {
|
|
1940
|
-
return Ok(value);
|
|
1711
|
+
let bytes = serialized.expect("JSON should serialize");
|
|
1712
|
+
assert!(!bytes.is_empty(), "Serialized JSON should not be empty");
|
|
1941
1713
|
}
|
|
1942
1714
|
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1715
|
+
/// Test global runtime initialization
|
|
1716
|
+
#[test]
|
|
1717
|
+
fn test_global_runtime_initialization() {
|
|
1718
|
+
assert!(crate::server::global_runtime_raw().is_ok());
|
|
1946
1719
|
}
|
|
1947
1720
|
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1721
|
+
/// Test path normalization logic for routes
|
|
1722
|
+
#[test]
|
|
1723
|
+
fn test_route_path_patterns() {
|
|
1724
|
+
let paths = vec![
|
|
1725
|
+
"/users",
|
|
1726
|
+
"/users/:id",
|
|
1727
|
+
"/users/:id/posts/:post_id",
|
|
1728
|
+
"/api/v1/resource",
|
|
1729
|
+
"/api-v2/users_list",
|
|
1730
|
+
"/resource.json",
|
|
1731
|
+
];
|
|
1953
1732
|
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
let inner_ref = client.inner.borrow();
|
|
1958
|
-
if let Some(inner) = inner_ref.as_ref() {
|
|
1959
|
-
for handler in &inner.handlers {
|
|
1960
|
-
handler.mark(marker);
|
|
1733
|
+
for path in paths {
|
|
1734
|
+
assert!(!path.is_empty());
|
|
1735
|
+
assert!(path.starts_with('/'));
|
|
1961
1736
|
}
|
|
1962
1737
|
}
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
/// Return the Spikard version.
|
|
1966
|
-
fn version() -> String {
|
|
1967
|
-
env!("CARGO_PKG_VERSION").to_string()
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
/// Build dependency container from Ruby dependencies
|
|
1971
|
-
///
|
|
1972
|
-
/// Converts Ruby dependencies (values and factories) to Rust DependencyContainer
|
|
1973
|
-
#[cfg(feature = "di")]
|
|
1974
|
-
fn build_dependency_container(
|
|
1975
|
-
ruby: &Ruby,
|
|
1976
|
-
dependencies: Value,
|
|
1977
|
-
) -> Result<spikard_core::di::DependencyContainer, Error> {
|
|
1978
|
-
use spikard_core::di::DependencyContainer;
|
|
1979
|
-
use std::sync::Arc;
|
|
1980
|
-
|
|
1981
|
-
if dependencies.is_nil() {
|
|
1982
|
-
return Ok(DependencyContainer::new());
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
let mut container = DependencyContainer::new();
|
|
1986
|
-
let deps_hash = RHash::try_convert(dependencies)?;
|
|
1987
|
-
|
|
1988
|
-
deps_hash.foreach(|key: String, value: Value| -> Result<ForEach, Error> {
|
|
1989
|
-
// Check if this is a factory (has a 'type' field set to :factory)
|
|
1990
|
-
if let Ok(dep_hash) = RHash::try_convert(value) {
|
|
1991
|
-
let dep_type: Option<String> = get_kw(ruby, dep_hash, "type").and_then(|v| {
|
|
1992
|
-
// Handle both symbol and string types
|
|
1993
|
-
if let Ok(sym) = magnus::Symbol::try_convert(v) {
|
|
1994
|
-
Some(sym.name().ok()?.to_string())
|
|
1995
|
-
} else {
|
|
1996
|
-
String::try_convert(v).ok()
|
|
1997
|
-
}
|
|
1998
|
-
});
|
|
1999
1738
|
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
.ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Factory missing 'factory' key"))?;
|
|
1739
|
+
/// Test HTTP method name validation
|
|
1740
|
+
#[test]
|
|
1741
|
+
fn test_http_method_names() {
|
|
1742
|
+
let methods = vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
2005
1743
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
let singleton: bool = get_kw(ruby, dep_hash, "singleton")
|
|
2011
|
-
.and_then(|v| bool::try_convert(v).ok())
|
|
2012
|
-
.unwrap_or(false);
|
|
2013
|
-
|
|
2014
|
-
let cacheable: bool = get_kw(ruby, dep_hash, "cacheable")
|
|
2015
|
-
.and_then(|v| bool::try_convert(v).ok())
|
|
2016
|
-
.unwrap_or(true);
|
|
2017
|
-
|
|
2018
|
-
let factory_dep =
|
|
2019
|
-
crate::di::RubyFactoryDependency::new(key.clone(), factory, depends_on, singleton, cacheable);
|
|
2020
|
-
|
|
2021
|
-
container.register(key.clone(), Arc::new(factory_dep)).map_err(|e| {
|
|
2022
|
-
Error::new(
|
|
2023
|
-
ruby.exception_runtime_error(),
|
|
2024
|
-
format!("Failed to register factory '{}': {}", key, e),
|
|
2025
|
-
)
|
|
2026
|
-
})?;
|
|
2027
|
-
}
|
|
2028
|
-
Some("value") => {
|
|
2029
|
-
// Value dependency
|
|
2030
|
-
let value_data = get_kw(ruby, dep_hash, "value").ok_or_else(|| {
|
|
2031
|
-
Error::new(ruby.exception_runtime_error(), "Value dependency missing 'value' key")
|
|
2032
|
-
})?;
|
|
2033
|
-
|
|
2034
|
-
let value_dep = crate::di::RubyValueDependency::new(key.clone(), value_data);
|
|
2035
|
-
|
|
2036
|
-
container.register(key.clone(), Arc::new(value_dep)).map_err(|e| {
|
|
2037
|
-
Error::new(
|
|
2038
|
-
ruby.exception_runtime_error(),
|
|
2039
|
-
format!("Failed to register value '{}': {}", key, e),
|
|
2040
|
-
)
|
|
2041
|
-
})?;
|
|
2042
|
-
}
|
|
2043
|
-
_ => {
|
|
2044
|
-
return Err(Error::new(
|
|
2045
|
-
ruby.exception_runtime_error(),
|
|
2046
|
-
format!("Invalid dependency type for '{}'", key),
|
|
2047
|
-
));
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
} else {
|
|
2051
|
-
// Treat as raw value
|
|
2052
|
-
let value_dep = crate::di::RubyValueDependency::new(key.clone(), value);
|
|
2053
|
-
container.register(key.clone(), Arc::new(value_dep)).map_err(|e| {
|
|
2054
|
-
Error::new(
|
|
2055
|
-
ruby.exception_runtime_error(),
|
|
2056
|
-
format!("Failed to register value '{}': {}", key, e),
|
|
2057
|
-
)
|
|
2058
|
-
})?;
|
|
1744
|
+
for method in methods {
|
|
1745
|
+
assert!(!method.is_empty());
|
|
1746
|
+
assert!(method.chars().all(|c| c.is_uppercase()));
|
|
2059
1747
|
}
|
|
2060
|
-
|
|
2061
|
-
Ok(ForEach::Continue)
|
|
2062
|
-
})?;
|
|
2063
|
-
|
|
2064
|
-
Ok(container)
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
/// Helper to extract an optional string from a Ruby Hash
|
|
2068
|
-
fn get_optional_string_from_hash(hash: RHash, key: &str) -> Result<Option<String>, Error> {
|
|
2069
|
-
match hash.get(String::from(key)) {
|
|
2070
|
-
Some(v) if !v.is_nil() => Ok(Some(String::try_convert(v)?)),
|
|
2071
|
-
_ => Ok(None),
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
/// Helper to extract a required string from a Ruby Hash
|
|
2076
|
-
fn get_required_string_from_hash(hash: RHash, key: &str, ruby: &Ruby) -> Result<String, Error> {
|
|
2077
|
-
let value = hash
|
|
2078
|
-
.get(String::from(key))
|
|
2079
|
-
.ok_or_else(|| Error::new(ruby.exception_arg_error(), format!("missing required key '{}'", key)))?;
|
|
2080
|
-
if value.is_nil() {
|
|
2081
|
-
return Err(Error::new(
|
|
2082
|
-
ruby.exception_arg_error(),
|
|
2083
|
-
format!("key '{}' cannot be nil", key),
|
|
2084
|
-
));
|
|
2085
1748
|
}
|
|
2086
|
-
String::try_convert(value)
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
fn extract_files(ruby: &Ruby, files_value: Value) -> Result<Vec<MultipartFilePart>, Error> {
|
|
2090
|
-
let files_hash = RHash::try_convert(files_value)?;
|
|
2091
|
-
|
|
2092
|
-
let keys_array: RArray = files_hash.funcall("keys", ())?;
|
|
2093
|
-
let mut result = Vec::new();
|
|
2094
|
-
|
|
2095
|
-
for i in 0..keys_array.len() {
|
|
2096
|
-
let key_val = keys_array.entry::<Value>(i as isize)?;
|
|
2097
|
-
let field_name = String::try_convert(key_val)?;
|
|
2098
|
-
let value = files_hash
|
|
2099
|
-
.get(key_val)
|
|
2100
|
-
.ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Failed to get hash value"))?;
|
|
2101
|
-
|
|
2102
|
-
if let Some(outer_array) = RArray::from_value(value) {
|
|
2103
|
-
if outer_array.is_empty() {
|
|
2104
|
-
continue;
|
|
2105
|
-
}
|
|
2106
1749
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
1750
|
+
/// Test handler name generation
|
|
1751
|
+
#[test]
|
|
1752
|
+
fn test_handler_name_patterns() {
|
|
1753
|
+
let handler_names = vec![
|
|
1754
|
+
"list_users",
|
|
1755
|
+
"get_user",
|
|
1756
|
+
"create_user",
|
|
1757
|
+
"update_user",
|
|
1758
|
+
"delete_user",
|
|
1759
|
+
"get_user_posts",
|
|
1760
|
+
];
|
|
1761
|
+
|
|
1762
|
+
for name in handler_names {
|
|
1763
|
+
assert!(!name.is_empty());
|
|
1764
|
+
assert!(name.chars().all(|c| c.is_alphanumeric() || c == '_'));
|
|
2119
1765
|
}
|
|
2120
1766
|
}
|
|
2121
1767
|
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
if array.len() < 2 {
|
|
2131
|
-
return Err(Error::new(
|
|
2132
|
-
ruby.exception_arg_error(),
|
|
2133
|
-
"file Array must have at least 2 elements: [filename, content]",
|
|
2134
|
-
));
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
let filename: String = String::try_convert(array.shift()?)?;
|
|
2138
|
-
let content_str: String = String::try_convert(array.shift()?)?;
|
|
2139
|
-
let content = content_str.into_bytes();
|
|
2140
|
-
|
|
2141
|
-
let content_type: Option<String> = if !array.is_empty() {
|
|
2142
|
-
String::try_convert(array.shift()?).ok()
|
|
2143
|
-
} else {
|
|
2144
|
-
None
|
|
2145
|
-
};
|
|
2146
|
-
|
|
2147
|
-
Ok(MultipartFilePart {
|
|
2148
|
-
field_name: field_name.to_string(),
|
|
2149
|
-
filename,
|
|
2150
|
-
content,
|
|
2151
|
-
content_type,
|
|
2152
|
-
})
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
/// Extract ServerConfig from Ruby ServerConfig object
|
|
2156
|
-
fn extract_server_config(ruby: &Ruby, config_value: Value) -> Result<spikard_http::ServerConfig, Error> {
|
|
2157
|
-
use spikard_http::{
|
|
2158
|
-
ApiKeyConfig, CompressionConfig, ContactInfo, JwtConfig, LicenseInfo, OpenApiConfig, RateLimitConfig,
|
|
2159
|
-
ServerInfo, StaticFilesConfig,
|
|
2160
|
-
};
|
|
2161
|
-
use std::collections::HashMap;
|
|
2162
|
-
|
|
2163
|
-
let host: String = config_value.funcall("host", ())?;
|
|
2164
|
-
|
|
2165
|
-
let port: u32 = config_value.funcall("port", ())?;
|
|
2166
|
-
|
|
2167
|
-
let workers: usize = config_value.funcall("workers", ())?;
|
|
2168
|
-
|
|
2169
|
-
let enable_request_id: bool = config_value.funcall("enable_request_id", ())?;
|
|
2170
|
-
|
|
2171
|
-
let max_body_size_value: Value = config_value.funcall("max_body_size", ())?;
|
|
2172
|
-
let max_body_size = if max_body_size_value.is_nil() {
|
|
2173
|
-
None
|
|
2174
|
-
} else {
|
|
2175
|
-
Some(u64::try_convert(max_body_size_value)? as usize)
|
|
2176
|
-
};
|
|
2177
|
-
|
|
2178
|
-
let request_timeout_value: Value = config_value.funcall("request_timeout", ())?;
|
|
2179
|
-
let request_timeout = if request_timeout_value.is_nil() {
|
|
2180
|
-
None
|
|
2181
|
-
} else {
|
|
2182
|
-
Some(u64::try_convert(request_timeout_value)?)
|
|
2183
|
-
};
|
|
2184
|
-
|
|
2185
|
-
let graceful_shutdown: bool = config_value.funcall("graceful_shutdown", ())?;
|
|
2186
|
-
|
|
2187
|
-
let shutdown_timeout: u64 = config_value.funcall("shutdown_timeout", ())?;
|
|
2188
|
-
|
|
2189
|
-
let compression_value: Value = config_value.funcall("compression", ())?;
|
|
2190
|
-
let compression = if compression_value.is_nil() {
|
|
2191
|
-
None
|
|
2192
|
-
} else {
|
|
2193
|
-
let gzip: bool = compression_value.funcall("gzip", ())?;
|
|
2194
|
-
let brotli: bool = compression_value.funcall("brotli", ())?;
|
|
2195
|
-
let min_size: usize = compression_value.funcall("min_size", ())?;
|
|
2196
|
-
let quality: u32 = compression_value.funcall("quality", ())?;
|
|
2197
|
-
Some(CompressionConfig {
|
|
2198
|
-
gzip,
|
|
2199
|
-
brotli,
|
|
2200
|
-
min_size,
|
|
2201
|
-
quality,
|
|
2202
|
-
})
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
let rate_limit_value: Value = config_value.funcall("rate_limit", ())?;
|
|
2206
|
-
let rate_limit = if rate_limit_value.is_nil() {
|
|
2207
|
-
None
|
|
2208
|
-
} else {
|
|
2209
|
-
let per_second: u64 = rate_limit_value.funcall("per_second", ())?;
|
|
2210
|
-
let burst: u32 = rate_limit_value.funcall("burst", ())?;
|
|
2211
|
-
let ip_based: bool = rate_limit_value.funcall("ip_based", ())?;
|
|
2212
|
-
Some(RateLimitConfig {
|
|
2213
|
-
per_second,
|
|
2214
|
-
burst,
|
|
2215
|
-
ip_based,
|
|
2216
|
-
})
|
|
2217
|
-
};
|
|
2218
|
-
|
|
2219
|
-
let jwt_auth_value: Value = config_value.funcall("jwt_auth", ())?;
|
|
2220
|
-
let jwt_auth = if jwt_auth_value.is_nil() {
|
|
2221
|
-
None
|
|
2222
|
-
} else {
|
|
2223
|
-
let secret: String = jwt_auth_value.funcall("secret", ())?;
|
|
2224
|
-
let algorithm: String = jwt_auth_value.funcall("algorithm", ())?;
|
|
2225
|
-
let audience_value: Value = jwt_auth_value.funcall("audience", ())?;
|
|
2226
|
-
let audience = if audience_value.is_nil() {
|
|
2227
|
-
None
|
|
2228
|
-
} else {
|
|
2229
|
-
Some(Vec::<String>::try_convert(audience_value)?)
|
|
1768
|
+
/// Test multipart file handling structure
|
|
1769
|
+
#[test]
|
|
1770
|
+
fn test_multipart_file_part_structure() {
|
|
1771
|
+
let file_data = spikard_http::testing::MultipartFilePart {
|
|
1772
|
+
field_name: "file".to_string(),
|
|
1773
|
+
filename: "test.txt".to_string(),
|
|
1774
|
+
content: b"file content".to_vec(),
|
|
1775
|
+
content_type: Some("text/plain".to_string()),
|
|
2230
1776
|
};
|
|
2231
|
-
let issuer_value: Value = jwt_auth_value.funcall("issuer", ())?;
|
|
2232
|
-
let issuer = if issuer_value.is_nil() {
|
|
2233
|
-
None
|
|
2234
|
-
} else {
|
|
2235
|
-
Some(String::try_convert(issuer_value)?)
|
|
2236
|
-
};
|
|
2237
|
-
let leeway: u64 = jwt_auth_value.funcall("leeway", ())?;
|
|
2238
|
-
Some(JwtConfig {
|
|
2239
|
-
secret,
|
|
2240
|
-
algorithm,
|
|
2241
|
-
audience,
|
|
2242
|
-
issuer,
|
|
2243
|
-
leeway,
|
|
2244
|
-
})
|
|
2245
|
-
};
|
|
2246
1777
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
let keys: Vec<String> = api_key_auth_value.funcall("keys", ())?;
|
|
2252
|
-
let header_name: String = api_key_auth_value.funcall("header_name", ())?;
|
|
2253
|
-
Some(ApiKeyConfig { keys, header_name })
|
|
2254
|
-
};
|
|
2255
|
-
|
|
2256
|
-
let static_files_value: Value = config_value.funcall("static_files", ())?;
|
|
2257
|
-
let static_files_array = RArray::from_value(static_files_value)
|
|
2258
|
-
.ok_or_else(|| Error::new(ruby.exception_type_error(), "static_files must be an Array"))?;
|
|
2259
|
-
|
|
2260
|
-
let mut static_files = Vec::new();
|
|
2261
|
-
for i in 0..static_files_array.len() {
|
|
2262
|
-
let sf_value = static_files_array.entry::<Value>(i as isize)?;
|
|
2263
|
-
let directory: String = sf_value.funcall("directory", ())?;
|
|
2264
|
-
let route_prefix: String = sf_value.funcall("route_prefix", ())?;
|
|
2265
|
-
let index_file: bool = sf_value.funcall("index_file", ())?;
|
|
2266
|
-
let cache_control_value: Value = sf_value.funcall("cache_control", ())?;
|
|
2267
|
-
let cache_control = if cache_control_value.is_nil() {
|
|
2268
|
-
None
|
|
2269
|
-
} else {
|
|
2270
|
-
Some(String::try_convert(cache_control_value)?)
|
|
2271
|
-
};
|
|
2272
|
-
static_files.push(StaticFilesConfig {
|
|
2273
|
-
directory,
|
|
2274
|
-
route_prefix,
|
|
2275
|
-
index_file,
|
|
2276
|
-
cache_control,
|
|
2277
|
-
});
|
|
1778
|
+
assert_eq!(file_data.field_name, "file");
|
|
1779
|
+
assert_eq!(file_data.filename, "test.txt");
|
|
1780
|
+
assert!(!file_data.content.is_empty());
|
|
1781
|
+
assert_eq!(file_data.content_type, Some("text/plain".to_string()));
|
|
2278
1782
|
}
|
|
2279
1783
|
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
let enabled: bool = openapi_value.funcall("enabled", ())?;
|
|
2285
|
-
let title: String = openapi_value.funcall("title", ())?;
|
|
2286
|
-
let version: String = openapi_value.funcall("version", ())?;
|
|
2287
|
-
let description_value: Value = openapi_value.funcall("description", ())?;
|
|
2288
|
-
let description = if description_value.is_nil() {
|
|
2289
|
-
None
|
|
2290
|
-
} else {
|
|
2291
|
-
Some(String::try_convert(description_value)?)
|
|
2292
|
-
};
|
|
2293
|
-
let swagger_ui_path: String = openapi_value.funcall("swagger_ui_path", ())?;
|
|
2294
|
-
let redoc_path: String = openapi_value.funcall("redoc_path", ())?;
|
|
2295
|
-
let openapi_json_path: String = openapi_value.funcall("openapi_json_path", ())?;
|
|
2296
|
-
|
|
2297
|
-
let contact_value: Value = openapi_value.funcall("contact", ())?;
|
|
2298
|
-
let contact = if contact_value.is_nil() {
|
|
2299
|
-
None
|
|
2300
|
-
} else if let Some(contact_hash) = RHash::from_value(contact_value) {
|
|
2301
|
-
let name = get_optional_string_from_hash(contact_hash, "name")?;
|
|
2302
|
-
let email = get_optional_string_from_hash(contact_hash, "email")?;
|
|
2303
|
-
let url = get_optional_string_from_hash(contact_hash, "url")?;
|
|
2304
|
-
Some(ContactInfo { name, email, url })
|
|
2305
|
-
} else {
|
|
2306
|
-
let name_value: Value = contact_value.funcall("name", ())?;
|
|
2307
|
-
let email_value: Value = contact_value.funcall("email", ())?;
|
|
2308
|
-
let url_value: Value = contact_value.funcall("url", ())?;
|
|
2309
|
-
Some(ContactInfo {
|
|
2310
|
-
name: if name_value.is_nil() {
|
|
2311
|
-
None
|
|
2312
|
-
} else {
|
|
2313
|
-
Some(String::try_convert(name_value)?)
|
|
2314
|
-
},
|
|
2315
|
-
email: if email_value.is_nil() {
|
|
2316
|
-
None
|
|
2317
|
-
} else {
|
|
2318
|
-
Some(String::try_convert(email_value)?)
|
|
2319
|
-
},
|
|
2320
|
-
url: if url_value.is_nil() {
|
|
2321
|
-
None
|
|
2322
|
-
} else {
|
|
2323
|
-
Some(String::try_convert(url_value)?)
|
|
2324
|
-
},
|
|
2325
|
-
})
|
|
2326
|
-
};
|
|
2327
|
-
|
|
2328
|
-
let license_value: Value = openapi_value.funcall("license", ())?;
|
|
2329
|
-
let license = if license_value.is_nil() {
|
|
2330
|
-
None
|
|
2331
|
-
} else if let Some(license_hash) = RHash::from_value(license_value) {
|
|
2332
|
-
let name = get_required_string_from_hash(license_hash, "name", ruby)?;
|
|
2333
|
-
let url = get_optional_string_from_hash(license_hash, "url")?;
|
|
2334
|
-
Some(LicenseInfo { name, url })
|
|
2335
|
-
} else {
|
|
2336
|
-
let name: String = license_value.funcall("name", ())?;
|
|
2337
|
-
let url_value: Value = license_value.funcall("url", ())?;
|
|
2338
|
-
let url = if url_value.is_nil() {
|
|
2339
|
-
None
|
|
2340
|
-
} else {
|
|
2341
|
-
Some(String::try_convert(url_value)?)
|
|
2342
|
-
};
|
|
2343
|
-
Some(LicenseInfo { name, url })
|
|
2344
|
-
};
|
|
1784
|
+
/// Test response header case sensitivity concepts
|
|
1785
|
+
#[test]
|
|
1786
|
+
fn test_response_header_concepts() {
|
|
1787
|
+
use axum::http::HeaderName;
|
|
2345
1788
|
|
|
2346
|
-
let
|
|
2347
|
-
let servers_array = RArray::from_value(servers_value)
|
|
2348
|
-
.ok_or_else(|| Error::new(ruby.exception_type_error(), "servers must be an Array"))?;
|
|
1789
|
+
let names = vec!["content-type", "Content-Type", "CONTENT-TYPE"];
|
|
2349
1790
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
let (url, description) = if let Some(server_hash) = RHash::from_value(server_value) {
|
|
2355
|
-
let url = get_required_string_from_hash(server_hash, "url", ruby)?;
|
|
2356
|
-
let description = get_optional_string_from_hash(server_hash, "description")?;
|
|
2357
|
-
(url, description)
|
|
2358
|
-
} else {
|
|
2359
|
-
let url: String = server_value.funcall("url", ())?;
|
|
2360
|
-
let description_value: Value = server_value.funcall("description", ())?;
|
|
2361
|
-
let description = if description_value.is_nil() {
|
|
2362
|
-
None
|
|
2363
|
-
} else {
|
|
2364
|
-
Some(String::try_convert(description_value)?)
|
|
2365
|
-
};
|
|
2366
|
-
(url, description)
|
|
2367
|
-
};
|
|
2368
|
-
|
|
2369
|
-
servers.push(ServerInfo { url, description });
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
let security_schemes = HashMap::new();
|
|
2373
|
-
|
|
2374
|
-
Some(OpenApiConfig {
|
|
2375
|
-
enabled,
|
|
2376
|
-
title,
|
|
2377
|
-
version,
|
|
2378
|
-
description,
|
|
2379
|
-
swagger_ui_path,
|
|
2380
|
-
redoc_path,
|
|
2381
|
-
openapi_json_path,
|
|
2382
|
-
contact,
|
|
2383
|
-
license,
|
|
2384
|
-
servers,
|
|
2385
|
-
security_schemes,
|
|
2386
|
-
})
|
|
2387
|
-
};
|
|
2388
|
-
|
|
2389
|
-
Ok(spikard_http::ServerConfig {
|
|
2390
|
-
host,
|
|
2391
|
-
port: port as u16,
|
|
2392
|
-
workers,
|
|
2393
|
-
enable_request_id,
|
|
2394
|
-
max_body_size,
|
|
2395
|
-
request_timeout,
|
|
2396
|
-
compression,
|
|
2397
|
-
rate_limit,
|
|
2398
|
-
jwt_auth,
|
|
2399
|
-
api_key_auth,
|
|
2400
|
-
static_files,
|
|
2401
|
-
graceful_shutdown,
|
|
2402
|
-
shutdown_timeout,
|
|
2403
|
-
background_tasks: spikard_http::BackgroundTaskConfig::default(),
|
|
2404
|
-
openapi,
|
|
2405
|
-
lifecycle_hooks: None,
|
|
2406
|
-
di_container: None,
|
|
2407
|
-
})
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
/// Start the Spikard HTTP server from Ruby
|
|
2411
|
-
///
|
|
2412
|
-
/// Creates an Axum HTTP server in a dedicated background thread with its own Tokio runtime.
|
|
2413
|
-
///
|
|
2414
|
-
/// # Arguments
|
|
2415
|
-
///
|
|
2416
|
-
/// * `routes_json` - JSON string containing route metadata
|
|
2417
|
-
/// * `handlers` - Ruby Hash mapping handler_name => Proc
|
|
2418
|
-
/// * `config` - Ruby ServerConfig object with all middleware settings
|
|
2419
|
-
/// * `hooks_value` - Lifecycle hooks
|
|
2420
|
-
/// * `ws_handlers` - WebSocket handlers
|
|
2421
|
-
/// * `sse_producers` - SSE producers
|
|
2422
|
-
/// * `dependencies` - Dependency injection container
|
|
2423
|
-
///
|
|
2424
|
-
/// # Example (Ruby)
|
|
2425
|
-
///
|
|
2426
|
-
/// ```ruby
|
|
2427
|
-
/// config = Spikard::ServerConfig.new(host: '0.0.0.0', port: 8000)
|
|
2428
|
-
/// Spikard::Native.run_server(routes_json, handlers, config, hooks, ws, sse, deps)
|
|
2429
|
-
/// ```
|
|
2430
|
-
#[allow(clippy::too_many_arguments)]
|
|
2431
|
-
fn run_server(
|
|
2432
|
-
ruby: &Ruby,
|
|
2433
|
-
routes_json: String,
|
|
2434
|
-
handlers: Value,
|
|
2435
|
-
config_value: Value,
|
|
2436
|
-
hooks_value: Value,
|
|
2437
|
-
ws_handlers: Value,
|
|
2438
|
-
sse_producers: Value,
|
|
2439
|
-
dependencies: Value,
|
|
2440
|
-
) -> Result<(), Error> {
|
|
2441
|
-
use spikard_http::{SchemaRegistry, Server};
|
|
2442
|
-
use tracing::{error, info, warn};
|
|
2443
|
-
|
|
2444
|
-
let mut config = extract_server_config(ruby, config_value)?;
|
|
2445
|
-
|
|
2446
|
-
let host = config.host.clone();
|
|
2447
|
-
let port = config.port;
|
|
2448
|
-
|
|
2449
|
-
let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
|
|
2450
|
-
.map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {}", err)))?;
|
|
2451
|
-
|
|
2452
|
-
let handlers_hash = RHash::from_value(handlers).ok_or_else(|| {
|
|
2453
|
-
Error::new(
|
|
2454
|
-
ruby.exception_arg_error(),
|
|
2455
|
-
"handlers parameter must be a Hash of handler_name => Proc",
|
|
2456
|
-
)
|
|
2457
|
-
})?;
|
|
2458
|
-
|
|
2459
|
-
let json_module = ruby
|
|
2460
|
-
.class_object()
|
|
2461
|
-
.funcall::<_, _, Value>("const_get", ("JSON",))
|
|
2462
|
-
.map_err(|err| Error::new(ruby.exception_name_error(), format!("JSON module not found: {}", err)))?;
|
|
2463
|
-
|
|
2464
|
-
let schema_registry = SchemaRegistry::new();
|
|
2465
|
-
|
|
2466
|
-
let mut routes_with_handlers: Vec<(Route, Arc<dyn spikard_http::Handler>)> = Vec::new();
|
|
2467
|
-
|
|
2468
|
-
for route_meta in metadata {
|
|
2469
|
-
let route = Route::from_metadata(route_meta.clone(), &schema_registry)
|
|
2470
|
-
.map_err(|e| Error::new(ruby.exception_runtime_error(), format!("Failed to create route: {}", e)))?;
|
|
2471
|
-
|
|
2472
|
-
let handler_key = ruby.str_new(&route_meta.handler_name);
|
|
2473
|
-
let handler_value: Value = match handlers_hash.lookup(handler_key) {
|
|
2474
|
-
Ok(val) => val,
|
|
2475
|
-
Err(_) => {
|
|
2476
|
-
return Err(Error::new(
|
|
2477
|
-
ruby.exception_arg_error(),
|
|
2478
|
-
format!("Handler '{}' not found in handlers hash", route_meta.handler_name),
|
|
2479
|
-
));
|
|
2480
|
-
}
|
|
2481
|
-
};
|
|
2482
|
-
|
|
2483
|
-
let ruby_handler = RubyHandler::new_for_server(
|
|
2484
|
-
ruby,
|
|
2485
|
-
handler_value,
|
|
2486
|
-
route_meta.handler_name.clone(),
|
|
2487
|
-
route_meta.method.clone(),
|
|
2488
|
-
route_meta.path.clone(),
|
|
2489
|
-
json_module,
|
|
2490
|
-
&route,
|
|
2491
|
-
)?;
|
|
2492
|
-
|
|
2493
|
-
routes_with_handlers.push((route, Arc::new(ruby_handler) as Arc<dyn spikard_http::Handler>));
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
let lifecycle_hooks = if let Ok(registry) = <&NativeLifecycleRegistry>::try_convert(hooks_value) {
|
|
2497
|
-
Some(registry.take_hooks())
|
|
2498
|
-
} else if !hooks_value.is_nil() {
|
|
2499
|
-
let hooks_hash = RHash::from_value(hooks_value)
|
|
2500
|
-
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "lifecycle_hooks parameter must be a Hash"))?;
|
|
2501
|
-
|
|
2502
|
-
let mut hooks = spikard_http::LifecycleHooks::new();
|
|
2503
|
-
type RubyHookVec = Vec<Arc<dyn spikard_http::lifecycle::LifecycleHook<Request<Body>, Response<Body>>>>;
|
|
2504
|
-
|
|
2505
|
-
let extract_hooks = |key: &str| -> Result<RubyHookVec, Error> {
|
|
2506
|
-
let key_sym = ruby.to_symbol(key);
|
|
2507
|
-
if let Some(hooks_array) = hooks_hash.get(key_sym)
|
|
2508
|
-
&& !hooks_array.is_nil()
|
|
2509
|
-
{
|
|
2510
|
-
let array = RArray::from_value(hooks_array)
|
|
2511
|
-
.ok_or_else(|| Error::new(ruby.exception_type_error(), format!("{} must be an Array", key)))?;
|
|
2512
|
-
|
|
2513
|
-
let mut result = Vec::new();
|
|
2514
|
-
let len = array.len();
|
|
2515
|
-
for i in 0..len {
|
|
2516
|
-
let hook_value: Value = array.entry(i as isize)?;
|
|
2517
|
-
let name = format!("{}_{}", key, i);
|
|
2518
|
-
let ruby_hook = lifecycle::RubyLifecycleHook::new(name, hook_value);
|
|
2519
|
-
result.push(Arc::new(ruby_hook)
|
|
2520
|
-
as Arc<
|
|
2521
|
-
dyn spikard_http::lifecycle::LifecycleHook<Request<Body>, Response<Body>>,
|
|
2522
|
-
>);
|
|
2523
|
-
}
|
|
2524
|
-
return Ok(result);
|
|
2525
|
-
}
|
|
2526
|
-
Ok(Vec::new())
|
|
2527
|
-
};
|
|
2528
|
-
|
|
2529
|
-
for hook in extract_hooks("on_request")? {
|
|
2530
|
-
hooks.add_on_request(hook);
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
for hook in extract_hooks("pre_validation")? {
|
|
2534
|
-
hooks.add_pre_validation(hook);
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
for hook in extract_hooks("pre_handler")? {
|
|
2538
|
-
hooks.add_pre_handler(hook);
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
for hook in extract_hooks("on_response")? {
|
|
2542
|
-
hooks.add_on_response(hook);
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
for hook in extract_hooks("on_error")? {
|
|
2546
|
-
hooks.add_on_error(hook);
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
Some(hooks)
|
|
2550
|
-
} else {
|
|
2551
|
-
None
|
|
2552
|
-
};
|
|
2553
|
-
|
|
2554
|
-
config.lifecycle_hooks = lifecycle_hooks.map(Arc::new);
|
|
2555
|
-
|
|
2556
|
-
// Extract and register dependencies
|
|
2557
|
-
#[cfg(feature = "di")]
|
|
2558
|
-
{
|
|
2559
|
-
if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies) {
|
|
2560
|
-
config.di_container = Some(Arc::new(registry.take_container()?));
|
|
2561
|
-
} else if !dependencies.is_nil() {
|
|
2562
|
-
match build_dependency_container(ruby, dependencies) {
|
|
2563
|
-
Ok(container) => {
|
|
2564
|
-
config.di_container = Some(Arc::new(container));
|
|
2565
|
-
}
|
|
2566
|
-
Err(err) => {
|
|
2567
|
-
return Err(Error::new(
|
|
2568
|
-
ruby.exception_runtime_error(),
|
|
2569
|
-
format!("Failed to build DI container: {}", err),
|
|
2570
|
-
));
|
|
2571
|
-
}
|
|
2572
|
-
}
|
|
1791
|
+
for name in names {
|
|
1792
|
+
let parsed = HeaderName::from_bytes(name.as_bytes());
|
|
1793
|
+
assert!(parsed.is_ok(), "Header name should parse: {}", name);
|
|
2573
1794
|
}
|
|
2574
1795
|
}
|
|
2575
1796
|
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
let mut ws_endpoints = Vec::new();
|
|
2585
|
-
if !ws_handlers.is_nil() {
|
|
2586
|
-
let ws_hash = RHash::from_value(ws_handlers)
|
|
2587
|
-
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
|
|
2588
|
-
|
|
2589
|
-
ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
|
|
2590
|
-
let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
|
|
2591
|
-
Error::new(
|
|
2592
|
-
ruby.exception_runtime_error(),
|
|
2593
|
-
format!("Failed to create WebSocket handler: {}", e),
|
|
2594
|
-
)
|
|
2595
|
-
})?;
|
|
2596
|
-
|
|
2597
|
-
let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
|
|
2598
|
-
|
|
2599
|
-
ws_endpoints.push((path, ws_state));
|
|
2600
|
-
|
|
2601
|
-
Ok(ForEach::Continue)
|
|
2602
|
-
})?;
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
|
-
let mut sse_endpoints = Vec::new();
|
|
2606
|
-
if !sse_producers.is_nil() {
|
|
2607
|
-
let sse_hash = RHash::from_value(sse_producers)
|
|
2608
|
-
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "SSE producers must be a Hash"))?;
|
|
2609
|
-
|
|
2610
|
-
sse_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
|
|
2611
|
-
let producer_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
|
|
2612
|
-
Error::new(
|
|
2613
|
-
ruby.exception_runtime_error(),
|
|
2614
|
-
format!("Failed to create SSE producer: {}", e),
|
|
2615
|
-
)
|
|
2616
|
-
})?;
|
|
2617
|
-
|
|
2618
|
-
let sse_state = crate::sse::create_sse_state(ruby, producer_instance)?;
|
|
2619
|
-
|
|
2620
|
-
sse_endpoints.push((path, sse_state));
|
|
2621
|
-
|
|
2622
|
-
Ok(ForEach::Continue)
|
|
2623
|
-
})?;
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
use axum::routing::get;
|
|
2627
|
-
for (path, ws_state) in ws_endpoints {
|
|
2628
|
-
info!("Registered WebSocket endpoint: {}", path);
|
|
2629
|
-
app_router = app_router.route(
|
|
2630
|
-
&path,
|
|
2631
|
-
get(spikard_http::websocket_handler::<crate::websocket::RubyWebSocketHandler>).with_state(ws_state),
|
|
2632
|
-
);
|
|
2633
|
-
}
|
|
2634
|
-
|
|
2635
|
-
for (path, sse_state) in sse_endpoints {
|
|
2636
|
-
info!("Registered SSE endpoint: {}", path);
|
|
2637
|
-
app_router = app_router.route(
|
|
2638
|
-
&path,
|
|
2639
|
-
get(spikard_http::sse_handler::<crate::sse::RubySseEventProducer>).with_state(sse_state),
|
|
2640
|
-
);
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
let addr = format!("{}:{}", config.host, config.port);
|
|
2644
|
-
let socket_addr: std::net::SocketAddr = addr.parse().map_err(|e| {
|
|
2645
|
-
Error::new(
|
|
2646
|
-
ruby.exception_arg_error(),
|
|
2647
|
-
format!("Invalid socket address {}: {}", addr, e),
|
|
2648
|
-
)
|
|
2649
|
-
})?;
|
|
2650
|
-
|
|
2651
|
-
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
2652
|
-
.enable_all()
|
|
2653
|
-
.build()
|
|
2654
|
-
.map_err(|e| {
|
|
2655
|
-
Error::new(
|
|
2656
|
-
ruby.exception_runtime_error(),
|
|
2657
|
-
format!("Failed to create Tokio runtime: {}", e),
|
|
2658
|
-
)
|
|
2659
|
-
})?;
|
|
2660
|
-
|
|
2661
|
-
let background_config = config.background_tasks.clone();
|
|
2662
|
-
|
|
2663
|
-
runtime.block_on(async move {
|
|
2664
|
-
let listener = tokio::net::TcpListener::bind(socket_addr)
|
|
2665
|
-
.await
|
|
2666
|
-
.unwrap_or_else(|_| panic!("Failed to bind to {}", socket_addr));
|
|
2667
|
-
|
|
2668
|
-
info!("Server listening on {}", socket_addr);
|
|
2669
|
-
|
|
2670
|
-
let background_runtime = spikard_http::BackgroundRuntime::start(background_config.clone()).await;
|
|
2671
|
-
crate::background::install_handle(background_runtime.handle());
|
|
2672
|
-
|
|
2673
|
-
let serve_result = axum::serve(listener, app_router).await;
|
|
2674
|
-
|
|
2675
|
-
crate::background::clear_handle();
|
|
2676
|
-
|
|
2677
|
-
if let Err(err) = background_runtime.shutdown().await {
|
|
2678
|
-
warn!("Failed to drain background tasks during shutdown: {:?}", err);
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
if let Err(e) = serve_result {
|
|
2682
|
-
error!("Server error: {}", e);
|
|
2683
|
-
}
|
|
2684
|
-
});
|
|
2685
|
-
|
|
2686
|
-
Ok(())
|
|
2687
|
-
}
|
|
1797
|
+
/// Test error payload structure
|
|
1798
|
+
#[test]
|
|
1799
|
+
fn test_error_payload_structure() {
|
|
1800
|
+
let error_json = json!({
|
|
1801
|
+
"error": "Not Found",
|
|
1802
|
+
"code": "404",
|
|
1803
|
+
"details": {}
|
|
1804
|
+
});
|
|
2688
1805
|
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
/// correctness, and returns a canonical JSON string. This keeps Ruby-sourced
|
|
2693
|
-
/// metadata aligned with the Rust core types.
|
|
2694
|
-
fn normalize_route_metadata(_ruby: &Ruby, routes_json: String) -> Result<String, Error> {
|
|
2695
|
-
use spikard_http::SchemaRegistry;
|
|
2696
|
-
|
|
2697
|
-
let registry = SchemaRegistry::new();
|
|
2698
|
-
let routes: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
|
|
2699
|
-
.map_err(|err| Error::new(magnus::exception::arg_error(), format!("Invalid routes JSON: {err}")))?;
|
|
2700
|
-
|
|
2701
|
-
for route in &routes {
|
|
2702
|
-
Route::from_metadata(route.clone(), ®istry).map_err(|err| {
|
|
2703
|
-
Error::new(
|
|
2704
|
-
magnus::exception::runtime_error(),
|
|
2705
|
-
format!("Invalid route {} {}: {err}", route.method, route.path),
|
|
2706
|
-
)
|
|
2707
|
-
})?;
|
|
1806
|
+
assert_eq!(error_json["error"], "Not Found");
|
|
1807
|
+
assert_eq!(error_json["code"], "404");
|
|
1808
|
+
assert!(error_json["details"].is_object());
|
|
2708
1809
|
}
|
|
2709
|
-
|
|
2710
|
-
serde_json::to_string(&routes).map_err(|err| {
|
|
2711
|
-
Error::new(
|
|
2712
|
-
magnus::exception::runtime_error(),
|
|
2713
|
-
format!("Failed to serialise routes: {err}"),
|
|
2714
|
-
)
|
|
2715
|
-
})
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
#[magnus::init]
|
|
2719
|
-
pub fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
2720
|
-
let spikard = ruby.define_module("Spikard")?;
|
|
2721
|
-
spikard.define_singleton_method("version", function!(version, 0))?;
|
|
2722
|
-
let native = match spikard.const_get("Native") {
|
|
2723
|
-
Ok(module) => module,
|
|
2724
|
-
Err(_) => spikard.define_module("Native")?,
|
|
2725
|
-
};
|
|
2726
|
-
|
|
2727
|
-
native.define_singleton_method("run_server", function!(run_server, 7))?;
|
|
2728
|
-
native.define_singleton_method("normalize_route_metadata", function!(normalize_route_metadata, 1))?;
|
|
2729
|
-
native.define_singleton_method("background_run", function!(background::background_run, 1))?;
|
|
2730
|
-
native.define_singleton_method("build_route_metadata", function!(build_route_metadata, 11))?;
|
|
2731
|
-
native.define_singleton_method("build_response", function!(build_response, 4))?;
|
|
2732
|
-
native.define_singleton_method("build_streaming_response", function!(build_streaming_response, 3))?;
|
|
2733
|
-
|
|
2734
|
-
let class = native.define_class("TestClient", ruby.class_object())?;
|
|
2735
|
-
class.define_alloc_func::<NativeTestClient>();
|
|
2736
|
-
class.define_method("initialize", method!(NativeTestClient::initialize, 6))?;
|
|
2737
|
-
class.define_method("request", method!(NativeTestClient::request, 3))?;
|
|
2738
|
-
class.define_method("websocket", method!(NativeTestClient::websocket, 1))?;
|
|
2739
|
-
class.define_method("sse", method!(NativeTestClient::sse, 1))?;
|
|
2740
|
-
class.define_method("close", method!(NativeTestClient::close, 0))?;
|
|
2741
|
-
|
|
2742
|
-
let built_response_class = native.define_class("BuiltResponse", ruby.class_object())?;
|
|
2743
|
-
built_response_class.define_alloc_func::<NativeBuiltResponse>();
|
|
2744
|
-
built_response_class.define_method("status_code", method!(NativeBuiltResponse::status_code, 0))?;
|
|
2745
|
-
built_response_class.define_method("headers", method!(NativeBuiltResponse::headers, 0))?;
|
|
2746
|
-
|
|
2747
|
-
let lifecycle_registry_class = native.define_class("LifecycleRegistry", ruby.class_object())?;
|
|
2748
|
-
lifecycle_registry_class.define_alloc_func::<NativeLifecycleRegistry>();
|
|
2749
|
-
lifecycle_registry_class.define_method("add_on_request", method!(NativeLifecycleRegistry::add_on_request, 1))?;
|
|
2750
|
-
lifecycle_registry_class.define_method(
|
|
2751
|
-
"pre_validation",
|
|
2752
|
-
method!(NativeLifecycleRegistry::add_pre_validation, 1),
|
|
2753
|
-
)?;
|
|
2754
|
-
lifecycle_registry_class.define_method("pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
|
|
2755
|
-
lifecycle_registry_class.define_method("on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
|
|
2756
|
-
lifecycle_registry_class.define_method("on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
|
|
2757
|
-
|
|
2758
|
-
let dependency_registry_class = native.define_class("DependencyRegistry", ruby.class_object())?;
|
|
2759
|
-
dependency_registry_class.define_alloc_func::<NativeDependencyRegistry>();
|
|
2760
|
-
dependency_registry_class.define_method("register_value", method!(NativeDependencyRegistry::register_value, 2))?;
|
|
2761
|
-
dependency_registry_class.define_method(
|
|
2762
|
-
"register_factory",
|
|
2763
|
-
method!(NativeDependencyRegistry::register_factory, 5),
|
|
2764
|
-
)?;
|
|
2765
|
-
|
|
2766
|
-
let spikard_module = ruby.define_module("Spikard")?;
|
|
2767
|
-
test_websocket::init(ruby, &spikard_module)?;
|
|
2768
|
-
test_sse::init(ruby, &spikard_module)?;
|
|
2769
|
-
|
|
2770
|
-
Ok(())
|
|
2771
1810
|
}
|