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