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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -6
  3. data/ext/spikard_rb/Cargo.toml +2 -2
  4. data/lib/spikard/app.rb +33 -14
  5. data/lib/spikard/testing.rb +47 -12
  6. data/lib/spikard/version.rb +1 -1
  7. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  8. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
  9. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
  10. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  11. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  12. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
  13. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
  14. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
  15. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
  16. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
  17. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
  18. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  19. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
  20. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
  21. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
  22. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
  23. data/vendor/crates/spikard-core/Cargo.toml +4 -4
  24. data/vendor/crates/spikard-core/src/debug.rs +64 -0
  25. data/vendor/crates/spikard-core/src/di/container.rs +3 -27
  26. data/vendor/crates/spikard-core/src/di/factory.rs +1 -5
  27. data/vendor/crates/spikard-core/src/di/graph.rs +8 -47
  28. data/vendor/crates/spikard-core/src/di/mod.rs +1 -1
  29. data/vendor/crates/spikard-core/src/di/resolved.rs +1 -7
  30. data/vendor/crates/spikard-core/src/di/value.rs +2 -4
  31. data/vendor/crates/spikard-core/src/errors.rs +30 -0
  32. data/vendor/crates/spikard-core/src/http.rs +262 -0
  33. data/vendor/crates/spikard-core/src/lib.rs +1 -1
  34. data/vendor/crates/spikard-core/src/lifecycle.rs +764 -0
  35. data/vendor/crates/spikard-core/src/metadata.rs +389 -0
  36. data/vendor/crates/spikard-core/src/parameters.rs +1962 -159
  37. data/vendor/crates/spikard-core/src/problem.rs +34 -0
  38. data/vendor/crates/spikard-core/src/request_data.rs +966 -1
  39. data/vendor/crates/spikard-core/src/router.rs +263 -2
  40. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +688 -0
  41. data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +26 -268
  42. data/vendor/crates/spikard-http/Cargo.toml +12 -16
  43. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
  44. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
  45. data/vendor/crates/spikard-http/src/auth.rs +65 -16
  46. data/vendor/crates/spikard-http/src/background.rs +1614 -3
  47. data/vendor/crates/spikard-http/src/cors.rs +515 -0
  48. data/vendor/crates/spikard-http/src/debug.rs +65 -0
  49. data/vendor/crates/spikard-http/src/di_handler.rs +1322 -77
  50. data/vendor/crates/spikard-http/src/handler_response.rs +711 -0
  51. data/vendor/crates/spikard-http/src/handler_trait.rs +607 -5
  52. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +6 -0
  53. data/vendor/crates/spikard-http/src/lib.rs +33 -28
  54. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +81 -0
  55. data/vendor/crates/spikard-http/src/lifecycle.rs +765 -0
  56. data/vendor/crates/spikard-http/src/middleware/mod.rs +372 -117
  57. data/vendor/crates/spikard-http/src/middleware/multipart.rs +836 -10
  58. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +409 -43
  59. data/vendor/crates/spikard-http/src/middleware/validation.rs +513 -65
  60. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +345 -0
  61. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1055 -0
  62. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +473 -3
  63. data/vendor/crates/spikard-http/src/query_parser.rs +455 -31
  64. data/vendor/crates/spikard-http/src/response.rs +321 -0
  65. data/vendor/crates/spikard-http/src/server/handler.rs +1572 -9
  66. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +136 -0
  67. data/vendor/crates/spikard-http/src/server/mod.rs +875 -178
  68. data/vendor/crates/spikard-http/src/server/request_extraction.rs +674 -23
  69. data/vendor/crates/spikard-http/src/server/routing_factory.rs +599 -0
  70. data/vendor/crates/spikard-http/src/sse.rs +983 -21
  71. data/vendor/crates/spikard-http/src/testing/form.rs +38 -0
  72. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -2
  73. data/vendor/crates/spikard-http/src/testing.rs +7 -7
  74. data/vendor/crates/spikard-http/src/websocket.rs +1055 -4
  75. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
  76. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
  77. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
  78. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
  79. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
  80. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
  81. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
  82. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
  83. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
  84. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
  85. data/vendor/crates/spikard-rb/Cargo.toml +10 -4
  86. data/vendor/crates/spikard-rb/build.rs +196 -5
  87. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  88. data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +100 -109
  89. data/vendor/crates/spikard-rb/src/conversion.rs +121 -20
  90. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
  91. data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +12 -46
  92. data/vendor/crates/spikard-rb/src/handler.rs +100 -107
  93. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  94. data/vendor/crates/spikard-rb/src/lib.rs +467 -1428
  95. data/vendor/crates/spikard-rb/src/lifecycle.rs +1 -0
  96. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  97. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +447 -0
  98. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  99. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
  100. data/vendor/crates/spikard-rb/src/server.rs +47 -22
  101. data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +187 -40
  102. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  103. data/vendor/crates/spikard-rb/src/testing/websocket.rs +635 -0
  104. data/vendor/crates/spikard-rb/src/websocket.rs +178 -37
  105. metadata +46 -13
  106. data/vendor/crates/spikard-http/src/parameters.rs +0 -1
  107. data/vendor/crates/spikard-http/src/problem.rs +0 -1
  108. data/vendor/crates/spikard-http/src/router.rs +0 -1
  109. data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
  110. data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
  111. data/vendor/crates/spikard-http/src/validation.rs +0 -1
  112. data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
  113. /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
- //! - `test_client`: TestClient wrapper for integration testing
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 test_client;
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, Request, Response, StatusCode};
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 once_cell::sync::Lazy;
48
- use serde_json::{Map as JsonMap, Value as JsonValue};
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, snapshot_response,
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 tokio::runtime::{Builder, Runtime};
63
+ use std::time::Duration;
64
+ use url::Url;
63
65
 
64
- static GLOBAL_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
65
- Builder::new_current_thread()
66
- .enable_all()
67
- .build()
68
- .expect("Failed to initialise global Tokio runtime")
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
- #[allow(dead_code)]
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
- Ruby::get().unwrap().exception_runtime_error(),
170
- "Ruby VM unavailable while building streaming response",
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 into_parts(&self) -> Result<(HandlerResponse, Option<JsonValue>), Error> {
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
- fn mark(&self, marker: &Marker) {
421
- if let Ok(ruby) = Ruby::get() {
422
- for handle in self.gc_handles.borrow().iter() {
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 http_server = GLOBAL_RUNTIME
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 = GLOBAL_RUNTIME
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
- handlers: handler_refs,
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 response = GLOBAL_RUNTIME
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 handle =
674
- GLOBAL_RUNTIME.spawn(async move { spikard_http::testing::connect_websocket(&server, &path).await });
675
-
676
- let ws = GLOBAL_RUNTIME.block_on(async {
677
- handle
678
- .await
679
- .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("WebSocket task failed: {}", e)))
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 = test_websocket::WebSocketTestConnection::new(ws);
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 response = GLOBAL_RUNTIME
693
- .block_on(async {
694
- let axum_response = inner.transport_server.get(&path).await;
695
- snapshot_response(axum_response).await
696
- })
697
- .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", e)))?;
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
- test_sse::sse_stream_from_response(ruby, &response)
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
- impl ClientInner {}
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
- if let Some(validator) = &self.inner.request_validator
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
- let request_value = build_ruby_request(&ruby, &self.inner, &request_data, validated_params.as_ref())
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) = &request_data.dependencies {
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.is_some() && !files_opt.unwrap().is_nil();
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.unwrap();
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
- handler: &RubyHandlerInner,
1208
- request_data: &RequestData,
1209
- validated_params: Option<&JsonValue>,
1229
+ request_data: RequestData,
1230
+ validated_params: Option<JsonValue>,
1210
1231
  ) -> Result<Value, Error> {
1211
- let hash = ruby.hash_new();
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
- let query_value = json_to_ruby(ruby, &request_data.query_params)?;
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.into_parts()?;
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.into_parts()?;
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 ruby_value_to_json(ruby: &Ruby, json_module: Value, value: Value) -> Result<JsonValue, Error> {
1442
- if value.is_nil() {
1443
- return Ok(JsonValue::Null);
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 json_string: String = json_module.funcall("generate", (value,))?;
1447
- serde_json::from_str(&json_string).map_err(|err| {
1448
- Error::new(
1449
- ruby.exception_runtime_error(),
1450
- format!("Failed to convert Ruby value to JSON: {err}"),
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
- fn json_to_ruby(ruby: &Ruby, value: &JsonValue) -> Result<Value, Error> {
1456
- match value {
1457
- JsonValue::Null => Ok(ruby.qnil().as_value()),
1458
- JsonValue::Bool(b) => Ok(if *b {
1459
- ruby.qtrue().as_value()
1460
- } else {
1461
- ruby.qfalse().as_value()
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
- fn build_response(
1491
- ruby: &Ruby,
1492
- content: Value,
1493
- status_code: i64,
1494
- headers_value: Value,
1495
- content_type: Option<String>,
1496
- ) -> Result<NativeBuiltResponse, Error> {
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 headers = value_to_string_map(ruby, headers_value)?;
1501
- let mut header_pairs = header_pairs_from_map(headers)?;
1502
-
1503
- let has_content_type = header_pairs
1504
- .iter()
1505
- .any(|(name, _)| name == &HeaderName::from_static("content-type"));
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 mut body_json = None;
1513
- let body_bytes = if content.is_nil() {
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 json = ruby_value_to_json(ruby, json_module, content)?;
1520
- body_json = Some(json.clone());
1521
- serde_json::to_vec(&json).map_err(|err| {
1522
- Error::new(
1523
- ruby.exception_runtime_error(),
1524
- format!("Failed to serialise response body: {err}"),
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 mut response_builder = axum::http::Response::builder().status(status_u16);
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
- for (name, value) in &header_pairs {
1532
- response_builder = response_builder.header(name, value);
1533
- }
1484
+ let mut response_builder = axum::http::Response::builder().status(status);
1534
1485
 
1535
- if let Some(content_type) = content_type {
1536
- let header_value = HeaderValue::from_str(&content_type).map_err(|err| {
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 content type '{content_type}': {err}"),
1490
+ format!("Invalid header name '{}': {}", name, err),
1540
1491
  )
1541
1492
  })?;
1542
- response_builder = response_builder.header(HeaderName::from_static("content-type"), header_value.clone());
1543
- header_pairs.push((HeaderName::from_static("content-type"), header_value));
1544
- } else if !has_content_type && body_json.is_some() {
1545
- response_builder = response_builder.header(
1546
- HeaderName::from_static("content-type"),
1547
- HeaderValue::from_static("application/json"),
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 response = response_builder.body(Body::from(body_bytes)).map_err(|err| {
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: {err}"),
1518
+ format!("Failed to build response: {}", err),
1555
1519
  )
1556
1520
  })?;
1557
1521
 
1558
- Ok(NativeBuiltResponse::new(
1559
- HandlerResponse::from(response),
1560
- body_json,
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
- fn build_streaming_response(
1566
- ruby: &Ruby,
1567
- stream_value: Value,
1568
- status_code: i64,
1569
- headers_value: Value,
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
- if !stream_value.respond_to(ruby.intern("next"), false)? && !stream_value.respond_to(ruby.intern("each"), false)? {
1575
- return Err(Error::new(
1576
- ruby.exception_arg_error(),
1577
- "StreamingResponse requires an object responding to #next or #each",
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 headers = value_to_string_map(ruby, headers_value)?;
1582
- let enumerator = Arc::new(Opaque::from(stream_value));
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 handler_response = payload.into_response()?;
1590
- Ok(NativeBuiltResponse::new(
1591
- handler_response,
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
- fn map_to_ruby_hash(ruby: &Ruby, map: &HashMap<String, String>) -> Result<Value, Error> {
1598
- let hash = ruby.hash_new();
1599
- for (key, value) in map {
1600
- hash.aset(ruby.str_new(key), ruby.str_new(value))?;
1601
- }
1602
- Ok(hash.as_value())
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
- fn multimap_to_ruby_hash(ruby: &Ruby, map: &HashMap<String, Vec<String>>) -> Result<Value, Error> {
1606
- let hash = ruby.hash_new();
1607
- for (key, values) in map {
1608
- let array = ruby.ary_new();
1609
- for value in values {
1610
- array.push(ruby.str_new(value))?;
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
- fn problem_to_json(problem: &ProblemDetails) -> String {
1618
- problem
1619
- .to_json_pretty()
1620
- .unwrap_or_else(|err| format!("Failed to serialise problem details: {err}"))
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
- fn normalize_path_for_route(path: &str) -> String {
1624
- let has_trailing_slash = path.ends_with('/');
1625
- let segments = path.split('/').map(|segment| {
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 normalized = segments.collect::<Vec<_>>().join("/");
1634
- if has_trailing_slash && !normalized.ends_with('/') {
1635
- format!("{normalized}/")
1636
- } else {
1637
- normalized
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
- fn default_handler_name(method: &str, path: &str) -> String {
1642
- let normalized_path: String = path
1643
- .chars()
1644
- .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
1645
- .collect();
1646
- let trimmed = normalized_path.trim_matches('_');
1647
- let final_segment = if trimmed.is_empty() { "root" } else { trimmed };
1648
- format!("{}_{}", method.to_ascii_lowercase(), final_segment)
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
- fn extract_handler_dependencies_from_ruby(_ruby: &Ruby, handler_value: Value) -> Result<Vec<String>, Error> {
1652
- if handler_value.is_nil() {
1653
- return Ok(Vec::new());
1654
- }
1655
-
1656
- let params_value: Value = handler_value.funcall("parameters", ())?;
1657
- let params = RArray::try_convert(params_value)?;
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
- if kind_name == "key" || kind_name == "keyreq" {
1674
- dependencies.push(String::try_convert(name_val)?);
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
- Ok(dependencies)
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
- fn option_json_to_ruby(ruby: &Ruby, value: &Option<JsonValue>) -> Result<Value, Error> {
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
- fn cors_to_ruby(ruby: &Ruby, cors: &Option<spikard_http::CorsConfig>) -> Result<Value, Error> {
1691
- if let Some(cors_config) = cors {
1692
- let hash = ruby.hash_new();
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
- if let Some(expose_headers) = &cors_config.expose_headers {
1725
- let exposed = expose_headers.iter().map(|s| JsonValue::String(s.clone())).collect();
1726
- hash.aset(
1727
- ruby.to_symbol("expose_headers"),
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
- if let Some(max_age) = cors_config.max_age {
1733
- hash.aset(ruby.to_symbol("max_age"), ruby.integer_from_i64(max_age as i64))?;
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
- if let Some(allow_credentials) = cors_config.allow_credentials {
1737
- let bool_value: Value = if allow_credentials {
1738
- ruby.qtrue().as_value()
1739
- } else {
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
- Ok(hash.as_value())
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
- fn route_metadata_to_ruby(ruby: &Ruby, metadata: &RouteMetadata) -> Result<Value, Error> {
1752
- let hash = ruby.hash_new();
1658
+ /// Test HeaderName/HeaderValue construction
1659
+ #[test]
1660
+ fn test_header_construction() {
1661
+ use axum::http::{HeaderName, HeaderValue};
1753
1662
 
1754
- hash.aset(ruby.to_symbol("method"), ruby.str_new(&metadata.method))?;
1755
- hash.aset(ruby.to_symbol("path"), ruby.str_new(&metadata.path))?;
1756
- hash.aset(ruby.to_symbol("handler_name"), ruby.str_new(&metadata.handler_name))?;
1757
- let is_async_val: Value = if metadata.is_async {
1758
- ruby.qtrue().as_value()
1759
- } else {
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
- hash.aset(
1765
- ruby.to_symbol("request_schema"),
1766
- option_json_to_ruby(ruby, &metadata.request_schema)?,
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
- #[cfg(feature = "di")]
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
- Ok(hash.as_value())
1805
- }
1806
-
1807
- fn parse_cors_config(ruby: &Ruby, value: Value) -> Result<Option<spikard_http::CorsConfig>, Error> {
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
- let allowed_origins = hash
1815
- .get(ruby.to_symbol("allowed_origins"))
1816
- .and_then(|v| Vec::<String>::try_convert(v).ok())
1817
- .unwrap_or_default();
1818
- let allowed_methods = hash
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(), &registry).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
- if let Some(validator) = route.parameter_validator.as_ref() {
1926
- metadata.parameter_schema = Some(validator.schema().clone());
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
- route_metadata_to_ruby(ruby, &metadata)
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
- fn get_kw(ruby: &Ruby, hash: RHash, name: &str) -> Option<Value> {
1933
- let sym = ruby.intern(name);
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
- fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
1938
- let symbol_key = ruby.intern(name);
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
- let string_key = ruby.str_new(name);
1944
- if let Some(value) = handlers.get(string_key) {
1945
- return Ok(value);
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
- Err(Error::new(
1949
- ruby.exception_name_error(),
1950
- format!("Handler '{name}' not provided"),
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
- /// GC mark hook so Ruby keeps handler closures alive.
1955
- #[allow(dead_code)]
1956
- fn mark(client: &NativeTestClient, marker: &Marker) {
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
- match dep_type.as_deref() {
2001
- Some("factory") => {
2002
- // Factory dependency
2003
- let factory = get_kw(ruby, dep_hash, "factory")
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
- let depends_on: Vec<String> = get_kw(ruby, dep_hash, "depends_on")
2007
- .and_then(|v| Vec::<String>::try_convert(v).ok())
2008
- .unwrap_or_default();
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
- let first_elem = outer_array.entry::<Value>(0)?;
2108
-
2109
- if RArray::from_value(first_elem).is_some() {
2110
- for j in 0..outer_array.len() {
2111
- let file_array = outer_array.entry::<Value>(j as isize)?;
2112
- let file_data = extract_single_file(ruby, &field_name, file_array)?;
2113
- result.push(file_data);
2114
- }
2115
- } else {
2116
- let file_data = extract_single_file(ruby, &field_name, value)?;
2117
- result.push(file_data);
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
- Ok(result)
2123
- }
2124
-
2125
- /// Extract a single file from Ruby array [filename, content, content_type (optional)]
2126
- fn extract_single_file(ruby: &Ruby, field_name: &str, array_value: Value) -> Result<MultipartFilePart, Error> {
2127
- let array = RArray::from_value(array_value)
2128
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "file must be an Array [filename, content]"))?;
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
- let api_key_auth_value: Value = config_value.funcall("api_key_auth", ())?;
2248
- let api_key_auth = if api_key_auth_value.is_nil() {
2249
- None
2250
- } else {
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
- let openapi_value: Value = config_value.funcall("openapi", ())?;
2281
- let openapi = if openapi_value.is_nil() {
2282
- None
2283
- } else {
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 servers_value: Value = openapi_value.funcall("servers", ())?;
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
- let mut servers = Vec::new();
2351
- for i in 0..servers_array.len() {
2352
- let server_value = servers_array.entry::<Value>(i as isize)?;
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
- Server::init_logging();
2577
-
2578
- info!("Starting Spikard server on {}:{}", host, port);
2579
- info!("Registered {} routes", routes_with_handlers.len());
2580
-
2581
- let mut app_router = Server::with_handlers(config.clone(), routes_with_handlers)
2582
- .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("Failed to build router: {}", e)))?;
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
- /// Validate and normalize route metadata using the Rust RouteMetadata schema.
2690
- ///
2691
- /// Parses the provided JSON, compiles schemas/parameter validators to ensure
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(), &registry).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
  }