spikard 0.3.6 → 0.6.1

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