spikard 0.4.0-arm64-darwin-23

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 (138) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -0
  3. data/README.md +659 -0
  4. data/ext/spikard_rb/Cargo.toml +17 -0
  5. data/ext/spikard_rb/extconf.rb +10 -0
  6. data/ext/spikard_rb/src/lib.rs +6 -0
  7. data/lib/spikard/app.rb +405 -0
  8. data/lib/spikard/background.rb +27 -0
  9. data/lib/spikard/config.rb +396 -0
  10. data/lib/spikard/converters.rb +13 -0
  11. data/lib/spikard/handler_wrapper.rb +113 -0
  12. data/lib/spikard/provide.rb +214 -0
  13. data/lib/spikard/response.rb +173 -0
  14. data/lib/spikard/schema.rb +243 -0
  15. data/lib/spikard/sse.rb +111 -0
  16. data/lib/spikard/streaming_response.rb +44 -0
  17. data/lib/spikard/testing.rb +221 -0
  18. data/lib/spikard/upload_file.rb +131 -0
  19. data/lib/spikard/version.rb +5 -0
  20. data/lib/spikard/websocket.rb +59 -0
  21. data/lib/spikard.rb +43 -0
  22. data/sig/spikard.rbs +366 -0
  23. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
  24. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
  25. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  26. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
  27. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
  28. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  29. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  30. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
  31. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
  32. data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
  33. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
  34. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
  35. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
  36. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  37. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
  38. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
  39. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
  40. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
  41. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  42. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  43. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  44. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  45. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  46. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  47. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  48. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  49. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  50. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  51. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  52. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  53. data/vendor/crates/spikard-core/src/errors.rs +39 -0
  54. data/vendor/crates/spikard-core/src/http.rs +153 -0
  55. data/vendor/crates/spikard-core/src/lib.rs +29 -0
  56. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  57. data/vendor/crates/spikard-core/src/metadata.rs +397 -0
  58. data/vendor/crates/spikard-core/src/parameters.rs +723 -0
  59. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  60. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  61. data/vendor/crates/spikard-core/src/router.rs +249 -0
  62. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  63. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  64. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
  65. data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
  66. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  67. data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
  68. data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
  69. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  70. data/vendor/crates/spikard-http/src/background.rs +1562 -0
  71. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  72. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  73. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  74. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  75. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  76. data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
  77. data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
  78. data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
  79. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  80. data/vendor/crates/spikard-http/src/lib.rs +524 -0
  81. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  82. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  83. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  84. data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
  85. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
  86. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  87. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  88. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
  89. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
  90. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
  91. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  92. data/vendor/crates/spikard-http/src/response.rs +399 -0
  93. data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
  94. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  95. data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
  96. data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
  97. data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
  98. data/vendor/crates/spikard-http/src/sse.rs +961 -0
  99. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  100. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  101. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  102. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  103. data/vendor/crates/spikard-http/src/websocket.rs +831 -0
  104. data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
  105. data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
  106. data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
  107. data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
  108. data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
  109. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
  110. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
  111. data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
  112. data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
  113. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
  114. data/vendor/crates/spikard-rb/Cargo.toml +43 -0
  115. data/vendor/crates/spikard-rb/build.rs +199 -0
  116. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  117. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  118. data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
  119. data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
  120. data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
  121. data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
  122. data/vendor/crates/spikard-rb/src/handler.rs +612 -0
  123. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  124. data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
  125. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
  126. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  127. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
  128. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  129. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
  130. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  131. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  132. data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
  133. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  134. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
  135. data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
  136. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  137. data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
  138. metadata +213 -0
@@ -0,0 +1,1857 @@
1
+ #![allow(deprecated)]
2
+ #![deny(clippy::unwrap_used)]
3
+
4
+ //! Spikard Ruby bindings using Magnus FFI.
5
+ //!
6
+ //! This crate provides Ruby bindings for the Spikard HTTP toolkit, allowing
7
+ //! Ruby developers to build and test HTTP services with Rust performance.
8
+ //!
9
+ //! ## Modules
10
+ //!
11
+ //! - `testing`: Testing utilities (client, SSE, WebSocket)
12
+ //! - `handler`: RubyHandler trait implementation
13
+ //! - `di`: Dependency injection bridge for Ruby types
14
+ //! - `config`: ServerConfig extraction from Ruby objects
15
+ //! - `conversion`: Ruby ↔ Rust type conversions
16
+ //! - `server`: HTTP server setup and lifecycle management
17
+ //! - `background`: Background task management
18
+ //! - `lifecycle`: Lifecycle hook implementations
19
+ //! - `sse`: Server-Sent Events support
20
+ //! - `websocket`: WebSocket support
21
+
22
+ mod background;
23
+ mod config;
24
+ mod conversion;
25
+ mod di;
26
+ mod handler;
27
+ mod integration;
28
+ mod lifecycle;
29
+ mod metadata;
30
+ mod runtime;
31
+ mod server;
32
+ mod sse;
33
+ mod testing;
34
+ mod websocket;
35
+
36
+ use async_stream::stream;
37
+ use axum::body::Body;
38
+ use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
39
+ use axum_test::{TestServer, TestServerConfig, Transport};
40
+ use bytes::Bytes;
41
+ use cookie::Cookie;
42
+ use magnus::prelude::*;
43
+ use magnus::value::{InnerValue, Opaque};
44
+ use magnus::{
45
+ Error, Module, RArray, RHash, RString, Ruby, TryConvert, Value, function, gc::Marker, method, r_hash::ForEach,
46
+ };
47
+ use once_cell::sync::Lazy;
48
+ use serde_json::{Map as JsonMap, Value as JsonValue};
49
+ use spikard_http::testing::{
50
+ MultipartFilePart, SnapshotError, build_multipart_body, encode_urlencoded_body, snapshot_response,
51
+ };
52
+ use spikard_http::{Handler, HandlerResponse, HandlerResult, RequestData};
53
+ use spikard_http::{ParameterValidator, ProblemDetails};
54
+ use spikard_http::{Route, RouteMetadata, SchemaValidator};
55
+ use std::cell::RefCell;
56
+ use std::collections::HashMap;
57
+ use std::io;
58
+ use std::mem;
59
+ use std::pin::Pin;
60
+ use std::sync::Arc;
61
+ use tokio::runtime::{Builder, Runtime};
62
+
63
+ use crate::config::extract_server_config;
64
+ use crate::conversion::{extract_files, map_to_ruby_hash, multimap_to_ruby_hash, problem_to_json};
65
+ use crate::integration::build_dependency_container;
66
+ use crate::metadata::{build_route_metadata, json_to_ruby, ruby_value_to_json};
67
+ use crate::runtime::{normalize_route_metadata, run_server};
68
+
69
+ static GLOBAL_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
70
+ Builder::new_current_thread()
71
+ .enable_all()
72
+ .build()
73
+ .expect("Failed to initialise global Tokio runtime")
74
+ });
75
+
76
+ #[derive(Default)]
77
+ #[magnus::wrap(class = "Spikard::Native::TestClient", free_immediately, mark)]
78
+ struct NativeTestClient {
79
+ inner: RefCell<Option<ClientInner>>,
80
+ }
81
+
82
+ struct ClientInner {
83
+ http_server: Arc<TestServer>,
84
+ transport_server: Arc<TestServer>,
85
+ /// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
86
+ _handlers: Vec<RubyHandler>,
87
+ }
88
+
89
+ struct RequestConfig {
90
+ query: Option<JsonValue>,
91
+ headers: HashMap<String, String>,
92
+ cookies: HashMap<String, String>,
93
+ body: Option<RequestBody>,
94
+ }
95
+
96
+ enum RequestBody {
97
+ Json(JsonValue),
98
+ Form(JsonValue),
99
+ Raw(String),
100
+ Multipart {
101
+ form_data: Vec<(String, String)>,
102
+ files: Vec<MultipartFilePart>,
103
+ },
104
+ }
105
+
106
+ #[derive(Clone)]
107
+ struct RubyHandler {
108
+ inner: Arc<RubyHandlerInner>,
109
+ }
110
+
111
+ struct RubyHandlerInner {
112
+ handler_proc: Opaque<Value>,
113
+ handler_name: String,
114
+ method: String,
115
+ path: String,
116
+ json_module: Opaque<Value>,
117
+ request_validator: Option<Arc<SchemaValidator>>,
118
+ response_validator: Option<Arc<SchemaValidator>>,
119
+ parameter_validator: Option<ParameterValidator>,
120
+ #[cfg(feature = "di")]
121
+ handler_dependencies: Vec<String>,
122
+ }
123
+
124
+ struct HandlerResponsePayload {
125
+ status: u16,
126
+ headers: HashMap<String, String>,
127
+ body: Option<JsonValue>,
128
+ raw_body: Option<Vec<u8>>,
129
+ }
130
+
131
+ struct NativeResponseParts {
132
+ response: HandlerResponse,
133
+ body_json: Option<JsonValue>,
134
+ }
135
+
136
+ enum RubyHandlerResult {
137
+ Payload(HandlerResponsePayload),
138
+ Streaming(StreamingResponsePayload),
139
+ Native(NativeResponseParts),
140
+ }
141
+
142
+ struct StreamingResponsePayload {
143
+ enumerator: Arc<Opaque<Value>>,
144
+ status: u16,
145
+ headers: HashMap<String, String>,
146
+ }
147
+
148
+ #[magnus::wrap(class = "Spikard::Native::BuiltResponse", free_immediately, mark)]
149
+ struct NativeBuiltResponse {
150
+ response: RefCell<Option<HandlerResponse>>,
151
+ body_json: Option<JsonValue>,
152
+ /// Ruby values that must be kept alive for GC (e.g., streaming enumerators)
153
+ #[allow(dead_code)]
154
+ gc_handles: Vec<Opaque<Value>>,
155
+ }
156
+
157
+ #[derive(Default)]
158
+ #[magnus::wrap(class = "Spikard::Native::LifecycleRegistry", free_immediately, mark)]
159
+ struct NativeLifecycleRegistry {
160
+ hooks: RefCell<spikard_http::LifecycleHooks>,
161
+ ruby_hooks: RefCell<Vec<Arc<crate::lifecycle::RubyLifecycleHook>>>,
162
+ }
163
+
164
+ #[magnus::wrap(class = "Spikard::Native::DependencyRegistry", free_immediately, mark)]
165
+ struct NativeDependencyRegistry {
166
+ container: RefCell<Option<spikard_core::di::DependencyContainer>>,
167
+ #[allow(dead_code)]
168
+ gc_handles: RefCell<Vec<Opaque<Value>>>,
169
+ registered_keys: RefCell<Vec<String>>,
170
+ }
171
+
172
+ impl StreamingResponsePayload {
173
+ fn into_response(self) -> Result<HandlerResponse, Error> {
174
+ // Get Ruby VM reference. In FFI, Ruby must be available during this callback.
175
+ // If Ruby becomes unavailable, this is a fatal error condition.
176
+ let ruby = match Ruby::get() {
177
+ Ok(r) => r,
178
+ Err(_) => {
179
+ // Ruby VM is unavailable. This should never happen during active FFI.
180
+ // We panic because continuing without a Ruby VM is unsafe.
181
+ panic!("Ruby VM became unavailable during streaming response construction");
182
+ }
183
+ };
184
+
185
+ let status = StatusCode::from_u16(self.status).map_err(|err| {
186
+ Error::new(
187
+ ruby.exception_arg_error(),
188
+ format!("Invalid streaming status code {}: {}", self.status, err),
189
+ )
190
+ })?;
191
+
192
+ let header_pairs = self
193
+ .headers
194
+ .into_iter()
195
+ .map(|(name, value)| {
196
+ let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
197
+ Error::new(
198
+ ruby.exception_arg_error(),
199
+ format!("Invalid header name '{name}': {err}"),
200
+ )
201
+ })?;
202
+ let header_value = HeaderValue::from_str(&value).map_err(|err| {
203
+ Error::new(
204
+ ruby.exception_arg_error(),
205
+ format!("Invalid header value for '{name}': {err}"),
206
+ )
207
+ })?;
208
+ Ok((header_name, header_value))
209
+ })
210
+ .collect::<Result<Vec<_>, Error>>()?;
211
+
212
+ let enumerator = self.enumerator.clone();
213
+ let body_stream = stream! {
214
+ loop {
215
+ match poll_stream_chunk(&enumerator) {
216
+ Ok(Some(bytes)) => yield Ok(bytes),
217
+ Ok(None) => break,
218
+ Err(err) => {
219
+ yield Err(Box::new(err));
220
+ break;
221
+ }
222
+ }
223
+ }
224
+ };
225
+
226
+ let mut response = HandlerResponse::stream(body_stream).with_status(status);
227
+ for (name, value) in header_pairs {
228
+ response = response.with_header(name, value);
229
+ }
230
+ Ok(response)
231
+ }
232
+ }
233
+
234
+ impl NativeBuiltResponse {
235
+ #[allow(dead_code)]
236
+ fn new(response: HandlerResponse, body_json: Option<JsonValue>, gc_handles: Vec<Opaque<Value>>) -> Self {
237
+ Self {
238
+ response: RefCell::new(Some(response)),
239
+ body_json,
240
+ gc_handles,
241
+ }
242
+ }
243
+
244
+ fn extract_parts(&self) -> Result<(HandlerResponse, Option<JsonValue>), Error> {
245
+ let mut borrow = self.response.borrow_mut();
246
+ let response = borrow
247
+ .take()
248
+ .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "Native response already consumed"))?;
249
+ Ok((response, self.body_json.clone()))
250
+ }
251
+
252
+ fn status_code(&self) -> u16 {
253
+ let borrow = self.response.borrow();
254
+ let Some(response) = borrow.as_ref() else {
255
+ return StatusCode::OK.as_u16();
256
+ };
257
+
258
+ match response {
259
+ HandlerResponse::Response(resp) => resp.status().as_u16(),
260
+ HandlerResponse::Stream { status, .. } => status.as_u16(),
261
+ }
262
+ }
263
+
264
+ fn headers(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
265
+ let headers_hash = ruby.hash_new();
266
+ if let Some(response) = this.response.borrow().as_ref() {
267
+ match response {
268
+ HandlerResponse::Response(resp) => {
269
+ for (header_name, value) in resp.headers() {
270
+ let name = header_name.as_str();
271
+ if let Ok(value_str) = value.to_str() {
272
+ headers_hash.aset(ruby.str_new(name), ruby.str_new(value_str))?;
273
+ }
274
+ }
275
+ }
276
+ HandlerResponse::Stream { headers, .. } => {
277
+ for (header_name, value) in headers.iter() {
278
+ let name = header_name.as_str();
279
+ if let Ok(value_str) = value.to_str() {
280
+ headers_hash.aset(ruby.str_new(name), ruby.str_new(value_str))?;
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ Ok(headers_hash.as_value())
287
+ }
288
+
289
+ #[allow(dead_code)]
290
+ fn mark(&self, marker: &Marker) {
291
+ if let Ok(ruby) = Ruby::get() {
292
+ for handle in &self.gc_handles {
293
+ marker.mark(handle.get_inner_with(&ruby));
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ impl NativeLifecycleRegistry {
300
+ fn add_on_request(&self, hook_value: Value) -> Result<(), Error> {
301
+ self.add_hook("on_request", hook_value, |hooks, hook| hooks.add_on_request(hook))
302
+ }
303
+
304
+ fn add_pre_validation(&self, hook_value: Value) -> Result<(), Error> {
305
+ self.add_hook("pre_validation", hook_value, |hooks, hook| {
306
+ hooks.add_pre_validation(hook)
307
+ })
308
+ }
309
+
310
+ fn add_pre_handler(&self, hook_value: Value) -> Result<(), Error> {
311
+ self.add_hook("pre_handler", hook_value, |hooks, hook| hooks.add_pre_handler(hook))
312
+ }
313
+
314
+ fn add_on_response(&self, hook_value: Value) -> Result<(), Error> {
315
+ self.add_hook("on_response", hook_value, |hooks, hook| hooks.add_on_response(hook))
316
+ }
317
+
318
+ fn add_on_error(&self, hook_value: Value) -> Result<(), Error> {
319
+ self.add_hook("on_error", hook_value, |hooks, hook| hooks.add_on_error(hook))
320
+ }
321
+
322
+ fn take_hooks(&self) -> spikard_http::LifecycleHooks {
323
+ mem::take(&mut *self.hooks.borrow_mut())
324
+ }
325
+
326
+ #[allow(dead_code)]
327
+ fn mark(&self, marker: &Marker) {
328
+ for hook in self.ruby_hooks.borrow().iter() {
329
+ hook.mark(marker);
330
+ }
331
+ }
332
+
333
+ fn add_hook<F>(&self, kind: &str, hook_value: Value, push: F) -> Result<(), Error>
334
+ where
335
+ F: Fn(&mut spikard_http::LifecycleHooks, Arc<crate::lifecycle::RubyLifecycleHook>),
336
+ {
337
+ let idx = self.ruby_hooks.borrow().len();
338
+ let hook = Arc::new(crate::lifecycle::RubyLifecycleHook::new(
339
+ format!("{kind}_{idx}"),
340
+ hook_value,
341
+ ));
342
+
343
+ push(&mut self.hooks.borrow_mut(), hook.clone());
344
+ self.ruby_hooks.borrow_mut().push(hook);
345
+ Ok(())
346
+ }
347
+ }
348
+
349
+ impl Default for NativeDependencyRegistry {
350
+ fn default() -> Self {
351
+ Self {
352
+ container: RefCell::new(Some(spikard_core::di::DependencyContainer::new())),
353
+ gc_handles: RefCell::new(Vec::new()),
354
+ registered_keys: RefCell::new(Vec::new()),
355
+ }
356
+ }
357
+ }
358
+
359
+ impl NativeDependencyRegistry {
360
+ fn register_value(ruby: &Ruby, this: &Self, key: String, value: Value) -> Result<(), Error> {
361
+ let dependency = crate::di::RubyValueDependency::new(key.clone(), value);
362
+ this.register_dependency(ruby, key, Arc::new(dependency), Some(value))
363
+ }
364
+
365
+ fn register_factory(
366
+ ruby: &Ruby,
367
+ this: &Self,
368
+ key: String,
369
+ factory: Value,
370
+ depends_on: Value,
371
+ singleton: bool,
372
+ cacheable: bool,
373
+ ) -> Result<(), Error> {
374
+ let depends_on_vec = if depends_on.is_nil() {
375
+ Vec::new()
376
+ } else {
377
+ Vec::<String>::try_convert(depends_on)?
378
+ };
379
+
380
+ let dependency =
381
+ crate::di::RubyFactoryDependency::new(key.clone(), factory, depends_on_vec, singleton, cacheable);
382
+ this.register_dependency(ruby, key, Arc::new(dependency), Some(factory))
383
+ }
384
+
385
+ fn register_dependency(
386
+ &self,
387
+ ruby: &Ruby,
388
+ key: String,
389
+ dependency: Arc<dyn spikard_core::di::Dependency>,
390
+ gc_value: Option<Value>,
391
+ ) -> Result<(), Error> {
392
+ let mut container_ref = self.container.borrow_mut();
393
+ let container = container_ref
394
+ .as_mut()
395
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Dependency container already consumed"))?;
396
+
397
+ container.register(key.clone(), dependency).map_err(|err| {
398
+ Error::new(
399
+ ruby.exception_runtime_error(),
400
+ format!("Failed to register dependency '{key}': {err}"),
401
+ )
402
+ })?;
403
+
404
+ if let Some(val) = gc_value {
405
+ self.gc_handles.borrow_mut().push(Opaque::from(val));
406
+ }
407
+
408
+ self.registered_keys.borrow_mut().push(key);
409
+
410
+ Ok(())
411
+ }
412
+
413
+ #[allow(dead_code)]
414
+ fn mark(&self, marker: &Marker) {
415
+ if let Ok(ruby) = Ruby::get() {
416
+ for handle in self.gc_handles.borrow().iter() {
417
+ marker.mark(handle.get_inner_with(&ruby));
418
+ }
419
+ }
420
+ }
421
+
422
+ fn take_container(&self) -> Result<spikard_core::di::DependencyContainer, Error> {
423
+ let mut borrow = self.container.borrow_mut();
424
+ let container = borrow.take().ok_or_else(|| {
425
+ Error::new(
426
+ magnus::exception::runtime_error(),
427
+ "Dependency container already consumed",
428
+ )
429
+ })?;
430
+ Ok(container)
431
+ }
432
+
433
+ fn keys(&self) -> Vec<String> {
434
+ self.registered_keys.borrow().clone()
435
+ }
436
+ }
437
+
438
+ fn poll_stream_chunk(enumerator: &Arc<Opaque<Value>>) -> Result<Option<Bytes>, io::Error> {
439
+ let ruby = Ruby::get().map_err(|err| io::Error::other(err.to_string()))?;
440
+ let enum_value = enumerator.get_inner_with(&ruby);
441
+ match enum_value.funcall::<_, _, Value>("next", ()) {
442
+ Ok(chunk) => ruby_value_to_bytes(chunk).map(Some),
443
+ Err(err) => {
444
+ if err.is_kind_of(ruby.exception_stop_iteration()) {
445
+ Ok(None)
446
+ } else {
447
+ Err(io::Error::other(err.to_string()))
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ fn ruby_value_to_bytes(value: Value) -> Result<Bytes, io::Error> {
454
+ if let Ok(str_value) = RString::try_convert(value) {
455
+ let slice = unsafe { str_value.as_slice() };
456
+ return Ok(Bytes::copy_from_slice(slice));
457
+ }
458
+
459
+ if let Ok(vec_bytes) = Vec::<u8>::try_convert(value) {
460
+ return Ok(Bytes::from(vec_bytes));
461
+ }
462
+
463
+ Err(io::Error::other("Streaming chunks must be Strings or Arrays of bytes"))
464
+ }
465
+
466
+ struct TestResponseData {
467
+ status: u16,
468
+ headers: HashMap<String, String>,
469
+ body_text: Option<String>,
470
+ }
471
+
472
+ #[derive(Debug)]
473
+ struct NativeRequestError(String);
474
+
475
+ impl NativeTestClient {
476
+ #[allow(clippy::too_many_arguments)]
477
+ fn initialize(
478
+ ruby: &Ruby,
479
+ this: &Self,
480
+ routes_json: String,
481
+ handlers: Value,
482
+ config_value: Value,
483
+ ws_handlers: Value,
484
+ sse_producers: Value,
485
+ dependencies: Value,
486
+ ) -> Result<(), Error> {
487
+ let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
488
+ .map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {err}")))?;
489
+
490
+ let handlers_hash = RHash::from_value(handlers).ok_or_else(|| {
491
+ Error::new(
492
+ ruby.exception_arg_error(),
493
+ "handlers parameter must be a Hash of handler_name => Proc",
494
+ )
495
+ })?;
496
+
497
+ let json_module = ruby
498
+ .class_object()
499
+ .const_get("JSON")
500
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
501
+
502
+ let mut server_config = extract_server_config(ruby, config_value)?;
503
+
504
+ // Extract and register dependencies
505
+ #[cfg(feature = "di")]
506
+ {
507
+ if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies) {
508
+ server_config.di_container = Some(Arc::new(registry.take_container()?));
509
+ } else if !dependencies.is_nil() {
510
+ match build_dependency_container(ruby, dependencies) {
511
+ Ok(container) => {
512
+ server_config.di_container = Some(Arc::new(container));
513
+ }
514
+ Err(err) => {
515
+ return Err(Error::new(
516
+ ruby.exception_runtime_error(),
517
+ format!("Failed to build DI container: {}", err),
518
+ ));
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ let schema_registry = spikard_http::SchemaRegistry::new();
525
+ let mut prepared_routes = Vec::with_capacity(metadata.len());
526
+ let mut handler_refs = Vec::with_capacity(metadata.len());
527
+ let mut route_metadata_vec = Vec::with_capacity(metadata.len());
528
+
529
+ for meta in metadata.clone() {
530
+ let handler_value = fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
531
+ let route = Route::from_metadata(meta.clone(), &schema_registry)
532
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build route: {err}")))?;
533
+
534
+ let handler = RubyHandler::new(&route, handler_value, json_module)?;
535
+ prepared_routes.push((route, Arc::new(handler.clone()) as Arc<dyn spikard_http::Handler>));
536
+ handler_refs.push(handler);
537
+ route_metadata_vec.push(meta);
538
+ }
539
+
540
+ let mut router = spikard_http::server::build_router_with_handlers_and_config(
541
+ prepared_routes,
542
+ server_config,
543
+ route_metadata_vec,
544
+ )
545
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build router: {err}")))?;
546
+
547
+ let mut ws_endpoints = Vec::new();
548
+ if !ws_handlers.is_nil() {
549
+ let ws_hash = RHash::from_value(ws_handlers)
550
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
551
+
552
+ ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
553
+ let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
554
+ Error::new(
555
+ ruby.exception_runtime_error(),
556
+ format!("Failed to create WebSocket handler: {}", e),
557
+ )
558
+ })?;
559
+
560
+ let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
561
+
562
+ ws_endpoints.push((path, ws_state));
563
+
564
+ Ok(ForEach::Continue)
565
+ })?;
566
+ }
567
+
568
+ let mut sse_endpoints = Vec::new();
569
+ if !sse_producers.is_nil() {
570
+ let sse_hash = RHash::from_value(sse_producers)
571
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "SSE producers must be a Hash"))?;
572
+
573
+ sse_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
574
+ let producer_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
575
+ Error::new(
576
+ ruby.exception_runtime_error(),
577
+ format!("Failed to create SSE producer: {}", e),
578
+ )
579
+ })?;
580
+
581
+ let sse_state = crate::sse::create_sse_state(ruby, producer_instance)?;
582
+
583
+ sse_endpoints.push((path, sse_state));
584
+
585
+ Ok(ForEach::Continue)
586
+ })?;
587
+ }
588
+
589
+ use axum::routing::get;
590
+ for (path, ws_state) in ws_endpoints {
591
+ router = router.route(
592
+ &path,
593
+ get(spikard_http::websocket_handler::<crate::websocket::RubyWebSocketHandler>).with_state(ws_state),
594
+ );
595
+ }
596
+
597
+ for (path, sse_state) in sse_endpoints {
598
+ router = router.route(
599
+ &path,
600
+ get(spikard_http::sse_handler::<crate::sse::RubySseEventProducer>).with_state(sse_state),
601
+ );
602
+ }
603
+
604
+ let http_server = GLOBAL_RUNTIME
605
+ .block_on(async { TestServer::new(router.clone()) })
606
+ .map_err(|err| {
607
+ Error::new(
608
+ ruby.exception_runtime_error(),
609
+ format!("Failed to initialise test server: {err}"),
610
+ )
611
+ })?;
612
+
613
+ let ws_config = TestServerConfig {
614
+ transport: Some(Transport::HttpRandomPort),
615
+ ..Default::default()
616
+ };
617
+ let transport_server = GLOBAL_RUNTIME
618
+ .block_on(async { TestServer::new_with_config(router, ws_config) })
619
+ .map_err(|err| {
620
+ Error::new(
621
+ ruby.exception_runtime_error(),
622
+ format!("Failed to initialise WebSocket transport server: {err}"),
623
+ )
624
+ })?;
625
+
626
+ *this.inner.borrow_mut() = Some(ClientInner {
627
+ http_server: Arc::new(http_server),
628
+ transport_server: Arc::new(transport_server),
629
+ _handlers: handler_refs,
630
+ });
631
+
632
+ Ok(())
633
+ }
634
+
635
+ fn request(ruby: &Ruby, this: &Self, method: String, path: String, options: Value) -> Result<Value, Error> {
636
+ let inner_borrow = this.inner.borrow();
637
+ let inner = inner_borrow
638
+ .as_ref()
639
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
640
+ let method_upper = method.to_ascii_uppercase();
641
+ let http_method = Method::from_bytes(method_upper.as_bytes()).map_err(|err| {
642
+ Error::new(
643
+ ruby.exception_arg_error(),
644
+ format!("Unsupported method {method_upper}: {err}"),
645
+ )
646
+ })?;
647
+
648
+ let request_config = parse_request_config(ruby, options)?;
649
+
650
+ let response = GLOBAL_RUNTIME
651
+ .block_on(execute_request(
652
+ inner.http_server.clone(),
653
+ http_method,
654
+ path.clone(),
655
+ request_config,
656
+ ))
657
+ .map_err(|err| {
658
+ Error::new(
659
+ ruby.exception_runtime_error(),
660
+ format!("Request failed for {method_upper} {path}: {}", err.0),
661
+ )
662
+ })?;
663
+
664
+ response_to_ruby(ruby, response)
665
+ }
666
+
667
+ fn close(&self) -> Result<(), Error> {
668
+ *self.inner.borrow_mut() = None;
669
+ Ok(())
670
+ }
671
+
672
+ fn websocket(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
673
+ let inner_borrow = this.inner.borrow();
674
+ let inner = inner_borrow
675
+ .as_ref()
676
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
677
+
678
+ let server = Arc::clone(&inner.transport_server);
679
+
680
+ drop(inner_borrow);
681
+
682
+ let handle =
683
+ GLOBAL_RUNTIME.spawn(async move { spikard_http::testing::connect_websocket(&server, &path).await });
684
+
685
+ let ws = GLOBAL_RUNTIME.block_on(async {
686
+ handle
687
+ .await
688
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("WebSocket task failed: {}", e)))
689
+ })?;
690
+
691
+ let ws_conn = testing::websocket::WebSocketTestConnection::new(ws);
692
+ Ok(ruby.obj_wrap(ws_conn).as_value())
693
+ }
694
+
695
+ fn sse(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
696
+ let inner_borrow = this.inner.borrow();
697
+ let inner = inner_borrow
698
+ .as_ref()
699
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
700
+
701
+ let response = GLOBAL_RUNTIME
702
+ .block_on(async {
703
+ let axum_response = inner.transport_server.get(&path).await;
704
+ snapshot_response(axum_response).await
705
+ })
706
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", e)))?;
707
+
708
+ testing::sse::sse_stream_from_response(ruby, &response)
709
+ }
710
+ }
711
+
712
+ impl RubyHandler {
713
+ fn new(route: &Route, handler_value: Value, json_module: Value) -> Result<Self, Error> {
714
+ Ok(Self {
715
+ inner: Arc::new(RubyHandlerInner {
716
+ handler_proc: Opaque::from(handler_value),
717
+ handler_name: route.handler_name.clone(),
718
+ method: route.method.as_str().to_string(),
719
+ path: route.path.clone(),
720
+ json_module: Opaque::from(json_module),
721
+ request_validator: route.request_validator.clone(),
722
+ response_validator: route.response_validator.clone(),
723
+ parameter_validator: route.parameter_validator.clone(),
724
+ #[cfg(feature = "di")]
725
+ handler_dependencies: route.handler_dependencies.clone(),
726
+ }),
727
+ })
728
+ }
729
+
730
+ /// Create a new RubyHandler for server mode
731
+ ///
732
+ /// This is used by run_server to create handlers from Ruby Procs
733
+ fn new_for_server(
734
+ _ruby: &Ruby,
735
+ handler_value: Value,
736
+ handler_name: String,
737
+ method: String,
738
+ path: String,
739
+ json_module: Value,
740
+ route: &Route,
741
+ ) -> Result<Self, Error> {
742
+ Ok(Self {
743
+ inner: Arc::new(RubyHandlerInner {
744
+ handler_proc: Opaque::from(handler_value),
745
+ handler_name,
746
+ method,
747
+ path,
748
+ json_module: Opaque::from(json_module),
749
+ request_validator: route.request_validator.clone(),
750
+ response_validator: route.response_validator.clone(),
751
+ parameter_validator: route.parameter_validator.clone(),
752
+ #[cfg(feature = "di")]
753
+ handler_dependencies: route.handler_dependencies.clone(),
754
+ }),
755
+ })
756
+ }
757
+
758
+ /// Required by Ruby GC; invoked through the magnus mark hook.
759
+ #[allow(dead_code)]
760
+ fn mark(&self, marker: &Marker) {
761
+ if let Ok(ruby) = Ruby::get() {
762
+ let proc_val = self.inner.handler_proc.get_inner_with(&ruby);
763
+ marker.mark(proc_val);
764
+ }
765
+ }
766
+
767
+ fn handle(&self, request_data: RequestData) -> HandlerResult {
768
+ if let Some(validator) = &self.inner.request_validator
769
+ && let Err(errors) = validator.validate(&request_data.body)
770
+ {
771
+ let problem = ProblemDetails::from_validation_error(&errors);
772
+ let error_json = problem_to_json(&problem);
773
+ return Err((problem.status_code(), error_json));
774
+ }
775
+
776
+ let validated_params = if let Some(validator) = &self.inner.parameter_validator {
777
+ match validator.validate_and_extract(
778
+ &request_data.query_params,
779
+ request_data.raw_query_params.as_ref(),
780
+ request_data.path_params.as_ref(),
781
+ request_data.headers.as_ref(),
782
+ request_data.cookies.as_ref(),
783
+ ) {
784
+ Ok(value) => Some(value),
785
+ Err(errors) => {
786
+ let problem = ProblemDetails::from_validation_error(&errors);
787
+ return Err((problem.status_code(), problem_to_json(&problem)));
788
+ }
789
+ }
790
+ } else {
791
+ None
792
+ };
793
+
794
+ let ruby = Ruby::get().map_err(|_| {
795
+ (
796
+ StatusCode::INTERNAL_SERVER_ERROR,
797
+ "Ruby VM unavailable while invoking handler".to_string(),
798
+ )
799
+ })?;
800
+
801
+ let request_value = build_ruby_request(&ruby, &self.inner, &request_data, validated_params.as_ref())
802
+ .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
803
+
804
+ let handler_proc = self.inner.handler_proc.get_inner_with(&ruby);
805
+
806
+ // Extract resolved dependencies (if any) and convert to Ruby keyword arguments
807
+ #[cfg(feature = "di")]
808
+ let handler_result = {
809
+ if let Some(deps) = &request_data.dependencies {
810
+ // Build keyword arguments hash from dependencies
811
+ // ONLY include dependencies that the handler actually declared
812
+ let kwargs_hash = ruby.hash_new();
813
+
814
+ // Check if all required handler dependencies are present
815
+ // If any are missing, return error BEFORE calling handler
816
+ for key in &self.inner.handler_dependencies {
817
+ if !deps.contains(key) {
818
+ // Handler requires a dependency that was not resolved
819
+ // This should have been caught by DI system, but safety check here
820
+ return Err((
821
+ StatusCode::INTERNAL_SERVER_ERROR,
822
+ format!(
823
+ "Handler '{}' requires dependency '{}' which was not resolved",
824
+ self.inner.handler_name, key
825
+ ),
826
+ ));
827
+ }
828
+ }
829
+
830
+ // Filter dependencies: only pass those declared by the handler
831
+ for key in &self.inner.handler_dependencies {
832
+ if let Some(value) = deps.get_arc(key) {
833
+ // Check what type of dependency this is and extract Ruby value
834
+ let ruby_val = if let Some(wrapper) = value.downcast_ref::<crate::di::RubyValueWrapper>() {
835
+ // It's a Ruby value wrapper (singleton with preserved mutations)
836
+ // Get the raw Ruby value directly to preserve object identity
837
+ wrapper.get_value(&ruby)
838
+ } else if let Some(json) = value.downcast_ref::<serde_json::Value>() {
839
+ // It's already JSON (non-singleton or value dependency)
840
+ // Convert JSON to Ruby value
841
+ match crate::di::json_to_ruby(&ruby, json) {
842
+ Ok(val) => val,
843
+ Err(e) => {
844
+ return Err((
845
+ StatusCode::INTERNAL_SERVER_ERROR,
846
+ format!("Failed to convert dependency '{}' to Ruby: {}", key, e),
847
+ ));
848
+ }
849
+ }
850
+ } else {
851
+ return Err((
852
+ StatusCode::INTERNAL_SERVER_ERROR,
853
+ format!(
854
+ "Unknown dependency type for '{}': expected RubyValueWrapper or JSON",
855
+ key
856
+ ),
857
+ ));
858
+ };
859
+
860
+ // Add to kwargs hash
861
+ let key_sym = ruby.to_symbol(key);
862
+ if let Err(e) = kwargs_hash.aset(key_sym, ruby_val) {
863
+ return Err((
864
+ StatusCode::INTERNAL_SERVER_ERROR,
865
+ format!("Failed to add dependency '{}': {}", key, e),
866
+ ));
867
+ }
868
+ }
869
+ }
870
+
871
+ // Call handler with request and dependencies as keyword arguments
872
+ // Ruby 3.x requires keyword arguments to be passed differently than Ruby 2.x
873
+ // We'll create a Ruby lambda that calls the handler with ** splat operator
874
+ //
875
+ // Equivalent Ruby code:
876
+ // lambda { |req, kwargs| handler_proc.call(req, **kwargs) }.call(request, kwargs_hash)
877
+
878
+ let wrapper_code = ruby
879
+ .eval::<Value>(
880
+ r#"
881
+ lambda do |proc, request, kwargs|
882
+ proc.call(request, **kwargs)
883
+ end
884
+ "#,
885
+ )
886
+ .map_err(|e| {
887
+ (
888
+ StatusCode::INTERNAL_SERVER_ERROR,
889
+ format!("Failed to create kwarg wrapper: {}", e),
890
+ )
891
+ })?;
892
+
893
+ wrapper_code.funcall("call", (handler_proc, request_value, kwargs_hash))
894
+ } else {
895
+ // No dependencies, call with just request
896
+ handler_proc.funcall("call", (request_value,))
897
+ }
898
+ };
899
+
900
+ #[cfg(not(feature = "di"))]
901
+ let handler_result = handler_proc.funcall("call", (request_value,));
902
+
903
+ let response_value = match handler_result {
904
+ Ok(value) => value,
905
+ Err(err) => {
906
+ return Err((
907
+ StatusCode::INTERNAL_SERVER_ERROR,
908
+ format!("Handler '{}' failed: {}", self.inner.handler_name, err),
909
+ ));
910
+ }
911
+ };
912
+
913
+ let handler_result = interpret_handler_response(&ruby, &self.inner, response_value).map_err(|err| {
914
+ (
915
+ StatusCode::INTERNAL_SERVER_ERROR,
916
+ format!(
917
+ "Failed to interpret response from '{}': {}",
918
+ self.inner.handler_name, err
919
+ ),
920
+ )
921
+ })?;
922
+
923
+ let payload = match handler_result {
924
+ RubyHandlerResult::Native(native) => {
925
+ if let (Some(validator), Some(body)) = (&self.inner.response_validator, native.body_json.as_ref())
926
+ && let Err(errors) = validator.validate(body)
927
+ {
928
+ let problem = ProblemDetails::from_validation_error(&errors);
929
+ return Err((StatusCode::INTERNAL_SERVER_ERROR, problem_to_json(&problem)));
930
+ }
931
+
932
+ return Ok(native.response.into_response());
933
+ }
934
+ RubyHandlerResult::Streaming(streaming) => {
935
+ let response = streaming.into_response().map_err(|err| {
936
+ (
937
+ StatusCode::INTERNAL_SERVER_ERROR,
938
+ format!("Failed to build streaming response: {}", err),
939
+ )
940
+ })?;
941
+ return Ok(response.into_response());
942
+ }
943
+ RubyHandlerResult::Payload(payload) => payload,
944
+ };
945
+
946
+ if let (Some(validator), Some(body)) = (&self.inner.response_validator, payload.body.as_ref())
947
+ && let Err(errors) = validator.validate(body)
948
+ {
949
+ let problem = ProblemDetails::from_validation_error(&errors);
950
+ return Err((StatusCode::INTERNAL_SERVER_ERROR, problem_to_json(&problem)));
951
+ }
952
+
953
+ let HandlerResponsePayload {
954
+ status,
955
+ headers,
956
+ body,
957
+ raw_body,
958
+ } = payload;
959
+
960
+ let mut response_builder = axum::http::Response::builder().status(status);
961
+ let mut has_content_type = false;
962
+
963
+ for (name, value) in headers.iter() {
964
+ if name.eq_ignore_ascii_case("content-type") {
965
+ has_content_type = true;
966
+ }
967
+ let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
968
+ (
969
+ StatusCode::INTERNAL_SERVER_ERROR,
970
+ format!("Invalid header name '{name}': {err}"),
971
+ )
972
+ })?;
973
+ let header_value = HeaderValue::from_str(value).map_err(|err| {
974
+ (
975
+ StatusCode::INTERNAL_SERVER_ERROR,
976
+ format!("Invalid header value for '{name}': {err}"),
977
+ )
978
+ })?;
979
+
980
+ response_builder = response_builder.header(header_name, header_value);
981
+ }
982
+
983
+ if !has_content_type && body.is_some() {
984
+ response_builder = response_builder.header(
985
+ HeaderName::from_static("content-type"),
986
+ HeaderValue::from_static("application/json"),
987
+ );
988
+ }
989
+
990
+ let body_bytes = if let Some(raw) = raw_body {
991
+ raw
992
+ } else if let Some(json_value) = body {
993
+ serde_json::to_vec(&json_value).map_err(|err| {
994
+ (
995
+ StatusCode::INTERNAL_SERVER_ERROR,
996
+ format!("Failed to serialise response body: {err}"),
997
+ )
998
+ })?
999
+ } else {
1000
+ Vec::new()
1001
+ };
1002
+
1003
+ response_builder.body(Body::from(body_bytes)).map_err(|err| {
1004
+ (
1005
+ StatusCode::INTERNAL_SERVER_ERROR,
1006
+ format!("Failed to build response: {err}"),
1007
+ )
1008
+ })
1009
+ }
1010
+ }
1011
+
1012
+ impl Handler for RubyHandler {
1013
+ fn call(
1014
+ &self,
1015
+ _req: axum::http::Request<Body>,
1016
+ request_data: RequestData,
1017
+ ) -> Pin<Box<dyn std::future::Future<Output = HandlerResult> + Send + '_>> {
1018
+ let handler = self.clone();
1019
+ Box::pin(async move { handler.handle(request_data) })
1020
+ }
1021
+ }
1022
+
1023
+ async fn execute_request(
1024
+ server: Arc<TestServer>,
1025
+ method: Method,
1026
+ path: String,
1027
+ config: RequestConfig,
1028
+ ) -> Result<TestResponseData, NativeRequestError> {
1029
+ let mut request = match method {
1030
+ Method::GET => server.get(&path),
1031
+ Method::POST => server.post(&path),
1032
+ Method::PUT => server.put(&path),
1033
+ Method::PATCH => server.patch(&path),
1034
+ Method::DELETE => server.delete(&path),
1035
+ Method::HEAD => server.method(Method::HEAD, &path),
1036
+ Method::OPTIONS => server.method(Method::OPTIONS, &path),
1037
+ Method::TRACE => server.method(Method::TRACE, &path),
1038
+ other => return Err(NativeRequestError(format!("Unsupported HTTP method {other}"))),
1039
+ };
1040
+
1041
+ if let Some(query) = config.query {
1042
+ request = request.add_query_params(&query);
1043
+ }
1044
+
1045
+ for (name, value) in config.headers {
1046
+ request = request.add_header(name.as_str(), value.as_str());
1047
+ }
1048
+
1049
+ for (name, value) in config.cookies {
1050
+ request = request.add_cookie(Cookie::new(name, value));
1051
+ }
1052
+
1053
+ if let Some(body) = config.body {
1054
+ match body {
1055
+ RequestBody::Json(json_value) => {
1056
+ request = request.json(&json_value);
1057
+ }
1058
+ RequestBody::Form(form_value) => {
1059
+ let encoded = encode_urlencoded_body(&form_value)
1060
+ .map_err(|err| NativeRequestError(format!("Failed to encode form body: {err}")))?;
1061
+ request = request
1062
+ .content_type("application/x-www-form-urlencoded")
1063
+ .bytes(Bytes::from(encoded));
1064
+ }
1065
+ RequestBody::Raw(raw) => {
1066
+ request = request.bytes(Bytes::from(raw));
1067
+ }
1068
+ RequestBody::Multipart { form_data, files } => {
1069
+ let (multipart_body, boundary) = build_multipart_body(&form_data, &files);
1070
+ request = request
1071
+ .content_type(&format!("multipart/form-data; boundary={}", boundary))
1072
+ .bytes(Bytes::from(multipart_body));
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ let response = request.await;
1078
+ let snapshot = snapshot_response(response).await.map_err(snapshot_err_to_native)?;
1079
+ let body_text = if snapshot.body.is_empty() {
1080
+ None
1081
+ } else {
1082
+ Some(String::from_utf8_lossy(&snapshot.body).into_owned())
1083
+ };
1084
+
1085
+ Ok(TestResponseData {
1086
+ status: snapshot.status,
1087
+ headers: snapshot.headers,
1088
+ body_text,
1089
+ })
1090
+ }
1091
+
1092
+ fn snapshot_err_to_native(err: SnapshotError) -> NativeRequestError {
1093
+ NativeRequestError(err.to_string())
1094
+ }
1095
+
1096
+ fn parse_request_config(ruby: &Ruby, options: Value) -> Result<RequestConfig, Error> {
1097
+ if options.is_nil() {
1098
+ return Ok(RequestConfig {
1099
+ query: None,
1100
+ headers: HashMap::new(),
1101
+ cookies: HashMap::new(),
1102
+ body: None,
1103
+ });
1104
+ }
1105
+
1106
+ let hash = RHash::from_value(options)
1107
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "request options must be a Hash"))?;
1108
+
1109
+ let json_module = ruby
1110
+ .class_object()
1111
+ .const_get("JSON")
1112
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
1113
+
1114
+ let query = if let Some(value) = get_kw(ruby, hash, "query") {
1115
+ if value.is_nil() {
1116
+ None
1117
+ } else {
1118
+ Some(ruby_value_to_json(ruby, json_module, value)?)
1119
+ }
1120
+ } else {
1121
+ None
1122
+ };
1123
+
1124
+ let headers = if let Some(value) = get_kw(ruby, hash, "headers") {
1125
+ if value.is_nil() {
1126
+ HashMap::new()
1127
+ } else {
1128
+ let hash = RHash::try_convert(value)?;
1129
+ hash.to_hash_map::<String, String>()?
1130
+ }
1131
+ } else {
1132
+ HashMap::new()
1133
+ };
1134
+
1135
+ let cookies = if let Some(value) = get_kw(ruby, hash, "cookies") {
1136
+ if value.is_nil() {
1137
+ HashMap::new()
1138
+ } else {
1139
+ let hash = RHash::try_convert(value)?;
1140
+ hash.to_hash_map::<String, String>()?
1141
+ }
1142
+ } else {
1143
+ HashMap::new()
1144
+ };
1145
+
1146
+ let files_opt = get_kw(ruby, hash, "files");
1147
+ let has_files = files_opt.as_ref().is_some_and(|f| !f.is_nil());
1148
+
1149
+ let body = if has_files {
1150
+ let files_value = files_opt.ok_or_else(|| {
1151
+ Error::new(
1152
+ ruby.exception_runtime_error(),
1153
+ "Files option should be Some if has_files is true",
1154
+ )
1155
+ })?;
1156
+ let files = extract_files(ruby, files_value)?;
1157
+
1158
+ let mut form_data = Vec::new();
1159
+ if let Some(data_value) = get_kw(ruby, hash, "data")
1160
+ && !data_value.is_nil()
1161
+ {
1162
+ let data_hash = RHash::try_convert(data_value)?;
1163
+
1164
+ let keys_array: RArray = data_hash.funcall("keys", ())?;
1165
+
1166
+ for i in 0..keys_array.len() {
1167
+ let key_val = keys_array.entry::<Value>(i as isize)?;
1168
+ let field_name = String::try_convert(key_val)?;
1169
+ let value = data_hash
1170
+ .get(key_val)
1171
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Failed to get hash value"))?;
1172
+
1173
+ if let Some(array) = RArray::from_value(value) {
1174
+ for j in 0..array.len() {
1175
+ let item = array.entry::<Value>(j as isize)?;
1176
+ let item_str = String::try_convert(item)?;
1177
+ form_data.push((field_name.clone(), item_str));
1178
+ }
1179
+ } else {
1180
+ let value_str = String::try_convert(value)?;
1181
+ form_data.push((field_name, value_str));
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ Some(RequestBody::Multipart { form_data, files })
1187
+ } else if let Some(value) = get_kw(ruby, hash, "json") {
1188
+ if value.is_nil() {
1189
+ None
1190
+ } else {
1191
+ Some(RequestBody::Json(ruby_value_to_json(ruby, json_module, value)?))
1192
+ }
1193
+ } else if let Some(value) = get_kw(ruby, hash, "data") {
1194
+ if value.is_nil() {
1195
+ None
1196
+ } else {
1197
+ Some(RequestBody::Form(ruby_value_to_json(ruby, json_module, value)?))
1198
+ }
1199
+ } else if let Some(value) = get_kw(ruby, hash, "raw_body") {
1200
+ if value.is_nil() {
1201
+ None
1202
+ } else {
1203
+ Some(RequestBody::Raw(String::try_convert(value)?))
1204
+ }
1205
+ } else {
1206
+ None
1207
+ };
1208
+
1209
+ Ok(RequestConfig {
1210
+ query,
1211
+ headers,
1212
+ cookies,
1213
+ body,
1214
+ })
1215
+ }
1216
+
1217
+ fn build_ruby_request(
1218
+ ruby: &Ruby,
1219
+ handler: &RubyHandlerInner,
1220
+ request_data: &RequestData,
1221
+ validated_params: Option<&JsonValue>,
1222
+ ) -> Result<Value, Error> {
1223
+ let hash = ruby.hash_new();
1224
+
1225
+ hash.aset(ruby.intern("method"), ruby.str_new(&handler.method))?;
1226
+ hash.aset(ruby.intern("path"), ruby.str_new(&handler.path))?;
1227
+
1228
+ let path_params = map_to_ruby_hash(ruby, request_data.path_params.as_ref())?;
1229
+ hash.aset(ruby.intern("path_params"), path_params)?;
1230
+
1231
+ let query_value = json_to_ruby(ruby, &request_data.query_params)?;
1232
+ hash.aset(ruby.intern("query"), query_value)?;
1233
+
1234
+ let raw_query = multimap_to_ruby_hash(ruby, request_data.raw_query_params.as_ref())?;
1235
+ hash.aset(ruby.intern("raw_query"), raw_query)?;
1236
+
1237
+ let headers = map_to_ruby_hash(ruby, request_data.headers.as_ref())?;
1238
+ hash.aset(ruby.intern("headers"), headers)?;
1239
+
1240
+ let cookies = map_to_ruby_hash(ruby, request_data.cookies.as_ref())?;
1241
+ hash.aset(ruby.intern("cookies"), cookies)?;
1242
+
1243
+ let body_value = json_to_ruby(ruby, &request_data.body)?;
1244
+ hash.aset(ruby.intern("body"), body_value)?;
1245
+
1246
+ let params_value = if let Some(validated) = validated_params {
1247
+ json_to_ruby(ruby, validated)?
1248
+ } else {
1249
+ build_default_params(ruby, request_data)?
1250
+ };
1251
+ hash.aset(ruby.intern("params"), params_value)?;
1252
+
1253
+ Ok(hash.as_value())
1254
+ }
1255
+
1256
+ fn build_default_params(ruby: &Ruby, request_data: &RequestData) -> Result<Value, Error> {
1257
+ let mut map = JsonMap::new();
1258
+
1259
+ for (key, value) in request_data.path_params.as_ref() {
1260
+ map.insert(key.clone(), JsonValue::String(value.clone()));
1261
+ }
1262
+
1263
+ if let JsonValue::Object(obj) = &request_data.query_params {
1264
+ for (key, value) in obj {
1265
+ map.insert(key.clone(), value.clone());
1266
+ }
1267
+ }
1268
+
1269
+ for (key, value) in request_data.headers.as_ref() {
1270
+ map.insert(key.clone(), JsonValue::String(value.clone()));
1271
+ }
1272
+
1273
+ for (key, value) in request_data.cookies.as_ref() {
1274
+ map.insert(key.clone(), JsonValue::String(value.clone()));
1275
+ }
1276
+
1277
+ json_to_ruby(ruby, &JsonValue::Object(map))
1278
+ }
1279
+
1280
+ fn interpret_handler_response(
1281
+ ruby: &Ruby,
1282
+ handler: &RubyHandlerInner,
1283
+ value: Value,
1284
+ ) -> Result<RubyHandlerResult, Error> {
1285
+ // Prefer native-built responses to avoid Ruby-side normalization overhead
1286
+ let native_method = ruby.intern("to_native_response");
1287
+ if value.respond_to(native_method, false)? {
1288
+ let native_value: Value = value.funcall("to_native_response", ())?;
1289
+ if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(native_value) {
1290
+ let (response, body_json) = native_resp.extract_parts()?;
1291
+ return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
1292
+ }
1293
+ } else if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(value) {
1294
+ let (response, body_json) = native_resp.extract_parts()?;
1295
+ return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
1296
+ }
1297
+
1298
+ if value.is_nil() {
1299
+ return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1300
+ status: 200,
1301
+ headers: HashMap::new(),
1302
+ body: None,
1303
+ raw_body: None,
1304
+ }));
1305
+ }
1306
+
1307
+ if is_streaming_response(ruby, value)? {
1308
+ let stream_value: Value = value.funcall("stream", ())?;
1309
+ let status: i64 = value.funcall("status_code", ())?;
1310
+ let headers_value: Value = value.funcall("headers", ())?;
1311
+
1312
+ let status_u16 = u16::try_from(status).map_err(|_| {
1313
+ Error::new(
1314
+ ruby.exception_arg_error(),
1315
+ "StreamingResponse status_code must be between 0 and 65535",
1316
+ )
1317
+ })?;
1318
+
1319
+ let headers = value_to_string_map(ruby, headers_value)?;
1320
+
1321
+ return Ok(RubyHandlerResult::Streaming(StreamingResponsePayload {
1322
+ enumerator: Arc::new(Opaque::from(stream_value)),
1323
+ status: status_u16,
1324
+ headers,
1325
+ }));
1326
+ }
1327
+
1328
+ let status_symbol = ruby.intern("status_code");
1329
+ if value.respond_to(status_symbol, false)? {
1330
+ let status: i64 = value.funcall("status_code", ())?;
1331
+ let status_u16 = u16::try_from(status)
1332
+ .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1333
+
1334
+ let headers_value: Value = value.funcall("headers", ())?;
1335
+ let headers = if headers_value.is_nil() {
1336
+ HashMap::new()
1337
+ } else {
1338
+ let hash = RHash::try_convert(headers_value)?;
1339
+ hash.to_hash_map::<String, String>()?
1340
+ };
1341
+
1342
+ let content_value: Value = value.funcall("content", ())?;
1343
+ let mut raw_body = None;
1344
+ let body = if content_value.is_nil() {
1345
+ None
1346
+ } else if let Ok(str_value) = RString::try_convert(content_value) {
1347
+ let slice = unsafe { str_value.as_slice() };
1348
+ raw_body = Some(slice.to_vec());
1349
+ None
1350
+ } else {
1351
+ Some(ruby_value_to_json(
1352
+ ruby,
1353
+ handler.json_module.get_inner_with(ruby),
1354
+ content_value,
1355
+ )?)
1356
+ };
1357
+
1358
+ return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1359
+ status: status_u16,
1360
+ headers,
1361
+ body,
1362
+ raw_body,
1363
+ }));
1364
+ }
1365
+
1366
+ if let Ok(str_value) = RString::try_convert(value) {
1367
+ let slice = unsafe { str_value.as_slice() };
1368
+ return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1369
+ status: 200,
1370
+ headers: HashMap::new(),
1371
+ body: None,
1372
+ raw_body: Some(slice.to_vec()),
1373
+ }));
1374
+ }
1375
+
1376
+ let body_json = ruby_value_to_json(ruby, handler.json_module.get_inner_with(ruby), value)?;
1377
+
1378
+ Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1379
+ status: 200,
1380
+ headers: HashMap::new(),
1381
+ body: Some(body_json),
1382
+ raw_body: None,
1383
+ }))
1384
+ }
1385
+
1386
+ fn value_to_string_map(ruby: &Ruby, value: Value) -> Result<HashMap<String, String>, Error> {
1387
+ if value.is_nil() {
1388
+ return Ok(HashMap::new());
1389
+ }
1390
+ let hash = RHash::try_convert(value)?;
1391
+ hash.to_hash_map::<String, String>().map_err(|err| {
1392
+ Error::new(
1393
+ ruby.exception_arg_error(),
1394
+ format!("Expected headers hash of strings: {}", err),
1395
+ )
1396
+ })
1397
+ }
1398
+
1399
+ #[allow(dead_code)]
1400
+ fn header_pairs_from_map(headers: HashMap<String, String>) -> Result<Vec<(HeaderName, HeaderValue)>, Error> {
1401
+ let ruby = Ruby::get().map_err(|err| Error::new(magnus::exception::runtime_error(), err.to_string()))?;
1402
+ headers
1403
+ .into_iter()
1404
+ .map(|(name, value)| {
1405
+ let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
1406
+ Error::new(
1407
+ ruby.exception_arg_error(),
1408
+ format!("Invalid header name '{name}': {err}"),
1409
+ )
1410
+ })?;
1411
+ let header_value = HeaderValue::from_str(&value).map_err(|err| {
1412
+ Error::new(
1413
+ ruby.exception_arg_error(),
1414
+ format!("Invalid header value for '{name}': {err}"),
1415
+ )
1416
+ })?;
1417
+ Ok((header_name, header_value))
1418
+ })
1419
+ .collect()
1420
+ }
1421
+
1422
+ fn is_streaming_response(ruby: &Ruby, value: Value) -> Result<bool, Error> {
1423
+ let stream_sym = ruby.intern("stream");
1424
+ let status_sym = ruby.intern("status_code");
1425
+ Ok(value.respond_to(stream_sym, false)? && value.respond_to(status_sym, false)?)
1426
+ }
1427
+
1428
+ fn response_to_ruby(ruby: &Ruby, response: TestResponseData) -> Result<Value, Error> {
1429
+ let hash = ruby.hash_new();
1430
+
1431
+ hash.aset(
1432
+ ruby.intern("status_code"),
1433
+ ruby.integer_from_i64(response.status as i64),
1434
+ )?;
1435
+
1436
+ let headers_hash = ruby.hash_new();
1437
+ for (key, value) in response.headers {
1438
+ headers_hash.aset(ruby.str_new(&key), ruby.str_new(&value))?;
1439
+ }
1440
+ hash.aset(ruby.intern("headers"), headers_hash)?;
1441
+
1442
+ if let Some(body) = response.body_text {
1443
+ let body_value = ruby.str_new(&body);
1444
+ hash.aset(ruby.intern("body"), body_value)?;
1445
+ hash.aset(ruby.intern("body_text"), body_value)?;
1446
+ } else {
1447
+ hash.aset(ruby.intern("body"), ruby.qnil())?;
1448
+ hash.aset(ruby.intern("body_text"), ruby.qnil())?;
1449
+ }
1450
+
1451
+ Ok(hash.as_value())
1452
+ }
1453
+
1454
+ fn get_kw(ruby: &Ruby, hash: RHash, name: &str) -> Option<Value> {
1455
+ let sym = ruby.intern(name);
1456
+ hash.get(sym).or_else(|| hash.get(name))
1457
+ }
1458
+
1459
+ fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
1460
+ let symbol_key = ruby.intern(name);
1461
+ if let Some(value) = handlers.get(symbol_key) {
1462
+ return Ok(value);
1463
+ }
1464
+
1465
+ let string_key = ruby.str_new(name);
1466
+ if let Some(value) = handlers.get(string_key) {
1467
+ return Ok(value);
1468
+ }
1469
+
1470
+ Err(Error::new(
1471
+ ruby.exception_name_error(),
1472
+ format!("Handler '{name}' not provided"),
1473
+ ))
1474
+ }
1475
+
1476
+ /// GC mark hook so Ruby keeps handler closures alive.
1477
+ #[allow(dead_code)]
1478
+ fn mark(client: &NativeTestClient, marker: &Marker) {
1479
+ let inner_ref = client.inner.borrow();
1480
+ if let Some(inner) = inner_ref.as_ref() {
1481
+ for handler in &inner._handlers {
1482
+ handler.mark(marker);
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ /// Return the Spikard version.
1488
+ fn version() -> String {
1489
+ env!("CARGO_PKG_VERSION").to_string()
1490
+ }
1491
+
1492
+ /// Build a native response from content, status code, and headers.
1493
+ ///
1494
+ /// Called by `Spikard::Response` to construct native response objects.
1495
+ /// The content can be a String (raw body), Hash/Array (JSON), or nil.
1496
+ fn build_response(ruby: &Ruby, content: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
1497
+ let status_u16 = u16::try_from(status_code)
1498
+ .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1499
+
1500
+ let header_map = if headers.is_nil() {
1501
+ HashMap::new()
1502
+ } else {
1503
+ let hash = RHash::try_convert(headers)?;
1504
+ hash.to_hash_map::<String, String>()?
1505
+ };
1506
+
1507
+ let (body_json, raw_body_opt) = if content.is_nil() {
1508
+ (None, None)
1509
+ } else if let Ok(str_value) = RString::try_convert(content) {
1510
+ let slice = unsafe { str_value.as_slice() };
1511
+ (None, Some(slice.to_vec()))
1512
+ } else {
1513
+ let json_module = ruby
1514
+ .class_object()
1515
+ .const_get("JSON")
1516
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
1517
+ let json_value = ruby_value_to_json(ruby, json_module, content)?;
1518
+ (Some(json_value), None)
1519
+ };
1520
+
1521
+ // Build the Axum response
1522
+ let status = StatusCode::from_u16(status_u16).map_err(|err| {
1523
+ Error::new(
1524
+ ruby.exception_arg_error(),
1525
+ format!("Invalid status code {}: {}", status_u16, err),
1526
+ )
1527
+ })?;
1528
+
1529
+ let mut response_builder = axum::http::Response::builder().status(status);
1530
+
1531
+ for (name, value) in &header_map {
1532
+ let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
1533
+ Error::new(
1534
+ ruby.exception_arg_error(),
1535
+ format!("Invalid header name '{}': {}", name, err),
1536
+ )
1537
+ })?;
1538
+ let header_value = HeaderValue::from_str(value).map_err(|err| {
1539
+ Error::new(
1540
+ ruby.exception_arg_error(),
1541
+ format!("Invalid header value for '{}': {}", name, err),
1542
+ )
1543
+ })?;
1544
+ response_builder = response_builder.header(header_name, header_value);
1545
+ }
1546
+
1547
+ let body_bytes = if let Some(raw) = raw_body_opt {
1548
+ raw
1549
+ } else if let Some(json_value) = body_json.as_ref() {
1550
+ serde_json::to_vec(&json_value).map_err(|err| {
1551
+ Error::new(
1552
+ ruby.exception_runtime_error(),
1553
+ format!("Failed to serialise response body: {}", err),
1554
+ )
1555
+ })?
1556
+ } else {
1557
+ Vec::new()
1558
+ };
1559
+
1560
+ let axum_response = response_builder.body(Body::from(body_bytes)).map_err(|err| {
1561
+ Error::new(
1562
+ ruby.exception_runtime_error(),
1563
+ format!("Failed to build response: {}", err),
1564
+ )
1565
+ })?;
1566
+
1567
+ let handler_response = HandlerResponse::Response(axum_response);
1568
+ let native_response = NativeBuiltResponse::new(handler_response, body_json.clone(), Vec::new());
1569
+ Ok(ruby.obj_wrap(native_response).as_value())
1570
+ }
1571
+
1572
+ /// Build a native streaming response from stream, status code, and headers.
1573
+ ///
1574
+ /// Called by `Spikard::StreamingResponse` to construct native response objects.
1575
+ /// The stream must be an enumerator that responds to #next.
1576
+ fn build_streaming_response(ruby: &Ruby, stream: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
1577
+ let status_u16 = u16::try_from(status_code)
1578
+ .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1579
+
1580
+ let header_map = if headers.is_nil() {
1581
+ HashMap::new()
1582
+ } else {
1583
+ let hash = RHash::try_convert(headers)?;
1584
+ hash.to_hash_map::<String, String>()?
1585
+ };
1586
+
1587
+ // Verify the stream responds to #next
1588
+ let next_method = ruby.intern("next");
1589
+ if !stream.respond_to(next_method, false)? {
1590
+ return Err(Error::new(ruby.exception_arg_error(), "stream must respond to #next"));
1591
+ }
1592
+
1593
+ let streaming_payload = StreamingResponsePayload {
1594
+ enumerator: Arc::new(Opaque::from(stream)),
1595
+ status: status_u16,
1596
+ headers: header_map,
1597
+ };
1598
+
1599
+ let response = streaming_payload.into_response()?;
1600
+ let native_response = NativeBuiltResponse::new(response, None, vec![Opaque::from(stream)]);
1601
+ Ok(ruby.obj_wrap(native_response).as_value())
1602
+ }
1603
+
1604
+ #[magnus::init]
1605
+ pub fn init(ruby: &Ruby) -> Result<(), Error> {
1606
+ let spikard = ruby.define_module("Spikard")?;
1607
+ spikard.define_singleton_method("version", function!(version, 0))?;
1608
+ let native = match spikard.const_get("Native") {
1609
+ Ok(module) => module,
1610
+ Err(_) => spikard.define_module("Native")?,
1611
+ };
1612
+
1613
+ native.define_singleton_method("run_server", function!(run_server, 7))?;
1614
+ native.define_singleton_method("normalize_route_metadata", function!(normalize_route_metadata, 1))?;
1615
+ native.define_singleton_method("background_run", function!(background::background_run, 1))?;
1616
+ native.define_singleton_method("build_route_metadata", function!(build_route_metadata, 11))?;
1617
+ native.define_singleton_method("build_response", function!(build_response, 3))?;
1618
+ native.define_singleton_method("build_streaming_response", function!(build_streaming_response, 3))?;
1619
+
1620
+ let class = native.define_class("TestClient", ruby.class_object())?;
1621
+ class.define_alloc_func::<NativeTestClient>();
1622
+ class.define_method("initialize", method!(NativeTestClient::initialize, 6))?;
1623
+ class.define_method("request", method!(NativeTestClient::request, 3))?;
1624
+ class.define_method("websocket", method!(NativeTestClient::websocket, 1))?;
1625
+ class.define_method("sse", method!(NativeTestClient::sse, 1))?;
1626
+ class.define_method("close", method!(NativeTestClient::close, 0))?;
1627
+
1628
+ let built_response_class = native.define_class("BuiltResponse", ruby.class_object())?;
1629
+ built_response_class.define_method("status_code", method!(NativeBuiltResponse::status_code, 0))?;
1630
+ built_response_class.define_method("headers", method!(NativeBuiltResponse::headers, 0))?;
1631
+
1632
+ let lifecycle_registry_class = native.define_class("LifecycleRegistry", ruby.class_object())?;
1633
+ lifecycle_registry_class.define_alloc_func::<NativeLifecycleRegistry>();
1634
+ lifecycle_registry_class.define_method("add_on_request", method!(NativeLifecycleRegistry::add_on_request, 1))?;
1635
+ lifecycle_registry_class.define_method(
1636
+ "pre_validation",
1637
+ method!(NativeLifecycleRegistry::add_pre_validation, 1),
1638
+ )?;
1639
+ lifecycle_registry_class.define_method("pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
1640
+ lifecycle_registry_class.define_method("on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
1641
+ lifecycle_registry_class.define_method("on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
1642
+
1643
+ let dependency_registry_class = native.define_class("DependencyRegistry", ruby.class_object())?;
1644
+ dependency_registry_class.define_alloc_func::<NativeDependencyRegistry>();
1645
+ dependency_registry_class.define_method("register_value", method!(NativeDependencyRegistry::register_value, 2))?;
1646
+ dependency_registry_class.define_method(
1647
+ "register_factory",
1648
+ method!(NativeDependencyRegistry::register_factory, 5),
1649
+ )?;
1650
+ dependency_registry_class.define_method("keys", method!(NativeDependencyRegistry::keys, 0))?;
1651
+
1652
+ let spikard_module = ruby.define_module("Spikard")?;
1653
+ testing::websocket::init(ruby, &spikard_module)?;
1654
+ testing::sse::init(ruby, &spikard_module)?;
1655
+
1656
+ // Touch GC mark hooks so the compiler keeps them and silence unused warnings.
1657
+ let _ = NativeBuiltResponse::mark as fn(&NativeBuiltResponse, &Marker);
1658
+ let _ = NativeLifecycleRegistry::mark as fn(&NativeLifecycleRegistry, &Marker);
1659
+ let _ = NativeDependencyRegistry::mark as fn(&NativeDependencyRegistry, &Marker);
1660
+ let _ = RubyHandler::mark as fn(&RubyHandler, &Marker);
1661
+ let _ = mark as fn(&NativeTestClient, &Marker);
1662
+
1663
+ Ok(())
1664
+ }
1665
+
1666
+ // ============================================================================
1667
+ // Unit Tests for Magnus FFI Layer
1668
+ // ============================================================================
1669
+
1670
+ #[cfg(test)]
1671
+ mod tests {
1672
+ use super::*;
1673
+ use serde_json::json;
1674
+
1675
+ /// Test that NativeBuiltResponse can extract parts safely
1676
+ #[test]
1677
+ fn test_native_built_response_status_extraction() {
1678
+ // Test that StatusCode::from_u16 works for valid codes
1679
+ use axum::http::StatusCode;
1680
+
1681
+ let valid_codes = vec![200u16, 201, 204, 301, 400, 404, 500, 503];
1682
+ for code in valid_codes {
1683
+ let status = StatusCode::from_u16(code);
1684
+ assert!(status.is_ok(), "Status code {} should be valid", code);
1685
+ }
1686
+ }
1687
+
1688
+ /// Test that invalid status codes are rejected
1689
+ #[test]
1690
+ fn test_native_built_response_invalid_status() {
1691
+ use axum::http::StatusCode;
1692
+
1693
+ // StatusCode validates up to 599 (3-digit codes). Higher values are invalid.
1694
+ // Testing edge cases at the boundary
1695
+ assert!(StatusCode::from_u16(599).is_ok(), "599 should be valid");
1696
+ }
1697
+
1698
+ /// Test HeaderName/HeaderValue construction
1699
+ #[test]
1700
+ fn test_header_construction() {
1701
+ use axum::http::{HeaderName, HeaderValue};
1702
+
1703
+ // Valid header names and values
1704
+ let valid_headers = vec![
1705
+ ("X-Custom-Header", "value"),
1706
+ ("Content-Type", "application/json"),
1707
+ ("Cache-Control", "no-cache"),
1708
+ ("Accept", "*/*"),
1709
+ ];
1710
+
1711
+ for (name, value) in valid_headers {
1712
+ let header_name = HeaderName::from_bytes(name.as_bytes());
1713
+ let header_value = HeaderValue::from_str(value);
1714
+
1715
+ assert!(header_name.is_ok(), "Header name '{}' should be valid", name);
1716
+ assert!(header_value.is_ok(), "Header value '{}' should be valid", value);
1717
+ }
1718
+ }
1719
+
1720
+ /// Test invalid headers are rejected
1721
+ #[test]
1722
+ fn test_invalid_header_construction() {
1723
+ use axum::http::{HeaderName, HeaderValue};
1724
+
1725
+ // Invalid header names (contain invalid bytes)
1726
+ let invalid_name = "X\nInvalid";
1727
+ assert!(
1728
+ HeaderName::from_bytes(invalid_name.as_bytes()).is_err(),
1729
+ "Header with newline should be invalid"
1730
+ );
1731
+
1732
+ // Invalid header value (null bytes)
1733
+ let invalid_value = "value\x00invalid";
1734
+ assert!(
1735
+ HeaderValue::from_str(invalid_value).is_err(),
1736
+ "Header with null byte should be invalid"
1737
+ );
1738
+ }
1739
+
1740
+ /// Test JSON serialization for responses
1741
+ #[test]
1742
+ fn test_json_response_serialization() {
1743
+ let json_obj = json!({
1744
+ "status": "success",
1745
+ "data": [1, 2, 3],
1746
+ "nested": {
1747
+ "key": "value"
1748
+ }
1749
+ });
1750
+
1751
+ let serialized = serde_json::to_vec(&json_obj);
1752
+ assert!(serialized.is_ok(), "JSON should serialize");
1753
+
1754
+ let bytes = serialized.unwrap();
1755
+ assert!(!bytes.is_empty(), "Serialized JSON should not be empty");
1756
+ }
1757
+
1758
+ /// Test global runtime initialization
1759
+ #[test]
1760
+ fn test_global_runtime_initialization() {
1761
+ // Verify that GLOBAL_RUNTIME can be accessed without panicking
1762
+ let _ = &*GLOBAL_RUNTIME;
1763
+ }
1764
+
1765
+ /// Test path normalization logic for routes
1766
+ #[test]
1767
+ fn test_route_path_patterns() {
1768
+ let paths = vec![
1769
+ "/users",
1770
+ "/users/:id",
1771
+ "/users/:id/posts/:post_id",
1772
+ "/api/v1/resource",
1773
+ "/api-v2/users_list",
1774
+ "/resource.json",
1775
+ ];
1776
+
1777
+ for path in paths {
1778
+ // Just verify we can work with these paths
1779
+ assert!(!path.is_empty());
1780
+ assert!(path.starts_with('/'));
1781
+ }
1782
+ }
1783
+
1784
+ /// Test HTTP method name validation
1785
+ #[test]
1786
+ fn test_http_method_names() {
1787
+ let methods = vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
1788
+
1789
+ for method in methods {
1790
+ assert!(!method.is_empty());
1791
+ assert!(method.chars().all(|c| c.is_uppercase()));
1792
+ }
1793
+ }
1794
+
1795
+ /// Test handler name generation
1796
+ #[test]
1797
+ fn test_handler_name_patterns() {
1798
+ let handler_names = vec![
1799
+ "list_users",
1800
+ "get_user",
1801
+ "create_user",
1802
+ "update_user",
1803
+ "delete_user",
1804
+ "get_user_posts",
1805
+ ];
1806
+
1807
+ for name in handler_names {
1808
+ assert!(!name.is_empty());
1809
+ assert!(name.chars().all(|c| c.is_alphanumeric() || c == '_'));
1810
+ }
1811
+ }
1812
+
1813
+ /// Test multipart file handling structure
1814
+ #[test]
1815
+ fn test_multipart_file_part_structure() {
1816
+ // Verify the concept of multipart files
1817
+ let file_data = spikard_http::testing::MultipartFilePart {
1818
+ field_name: "file".to_string(),
1819
+ filename: "test.txt".to_string(),
1820
+ content: b"file content".to_vec(),
1821
+ content_type: Some("text/plain".to_string()),
1822
+ };
1823
+
1824
+ assert_eq!(file_data.field_name, "file");
1825
+ assert_eq!(file_data.filename, "test.txt");
1826
+ assert!(!file_data.content.is_empty());
1827
+ assert_eq!(file_data.content_type, Some("text/plain".to_string()));
1828
+ }
1829
+
1830
+ /// Test response header case sensitivity concepts
1831
+ #[test]
1832
+ fn test_response_header_concepts() {
1833
+ use axum::http::HeaderName;
1834
+
1835
+ // HTTP header names are case-insensitive
1836
+ let names = vec!["content-type", "Content-Type", "CONTENT-TYPE"];
1837
+
1838
+ for name in names {
1839
+ let parsed = HeaderName::from_bytes(name.as_bytes());
1840
+ assert!(parsed.is_ok(), "Header name should parse: {}", name);
1841
+ }
1842
+ }
1843
+
1844
+ /// Test error payload structure
1845
+ #[test]
1846
+ fn test_error_payload_structure() {
1847
+ let error_json = json!({
1848
+ "error": "Not Found",
1849
+ "code": "404",
1850
+ "details": {}
1851
+ });
1852
+
1853
+ assert_eq!(error_json["error"], "Not Found");
1854
+ assert_eq!(error_json["code"], "404");
1855
+ assert!(error_json["details"].is_object());
1856
+ }
1857
+ }