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,551 +1,538 @@
1
- //! Native Ruby test client for HTTP testing.
2
- //!
3
- //! This module implements `NativeTestClient`, a wrapped Ruby class that provides
4
- //! HTTP testing capabilities against a Spikard server. It manages test servers
5
- //! for both HTTP and WebSocket/SSE transports.
6
-
7
- #![allow(dead_code)]
8
-
9
- use axum::http::Method;
10
- use axum::Router;
11
- use axum_test::{TestServer, TestServerConfig, Transport};
12
- use bytes::Bytes;
13
- use cookie::Cookie;
14
- use magnus::prelude::*;
15
- use magnus::{Error, RHash, Ruby, Value, gc::Marker};
16
- use serde_json::Value as JsonValue;
17
- use spikard_http::testing::{
18
- MultipartFilePart,
19
- ResponseSnapshot,
20
- SnapshotError,
21
- build_multipart_body,
22
- encode_urlencoded_body,
23
- snapshot_response,
24
- };
25
- use spikard_http::{Route, RouteMetadata};
26
- use std::cell::RefCell;
27
- use std::collections::HashMap;
28
- use std::sync::Arc;
29
- use std::time::Duration;
30
- use url::Url;
31
-
32
- use crate::conversion::{parse_request_config, response_to_ruby};
33
- use crate::handler::RubyHandler;
34
-
35
- /// Request configuration built from Ruby options hash.
36
- pub struct RequestConfig {
37
- pub query: Option<JsonValue>,
38
- pub headers: HashMap<String, String>,
39
- pub cookies: HashMap<String, String>,
40
- pub body: Option<RequestBody>,
41
- }
42
-
43
- /// HTTP request body variants.
44
- pub enum RequestBody {
45
- Json(JsonValue),
46
- Form(JsonValue),
47
- Raw(String),
48
- Multipart {
49
- form_data: Vec<(String, String)>,
50
- files: Vec<MultipartFilePart>,
51
- },
52
- }
53
-
54
- /// Snapshot of an HTTP response.
55
- pub struct TestResponseData {
56
- pub status: u16,
57
- pub headers: HashMap<String, String>,
58
- pub body_text: Option<String>,
59
- }
60
-
61
- /// Error wrapper for native request failures.
62
- #[derive(Debug)]
63
- pub struct NativeRequestError(pub String);
64
-
65
- #[derive(Debug)]
66
- enum WebSocketConnectError {
67
- Timeout,
68
- Other(String),
69
- }
70
-
71
- /// Inner client state containing the test servers and handlers.
72
- pub struct ClientInner {
73
- pub http_server: Arc<TestServer>,
74
- pub transport_server: Arc<TestServer>,
75
- /// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
76
- #[allow(dead_code)]
77
- pub handlers: Vec<RubyHandler>,
78
- }
79
-
80
- /// Native Ruby TestClient wrapper for integration testing.
81
- ///
82
- /// Wraps an optional `ClientInner` that holds the HTTP test servers
83
- /// and keeps handler references alive for Ruby's garbage collector.
84
- #[derive(Default)]
85
- #[magnus::wrap(class = "Spikard::Native::TestClient", free_immediately, mark)]
86
- pub struct NativeTestClient {
87
- pub inner: RefCell<Option<ClientInner>>,
88
- }
89
-
90
- impl NativeTestClient {
91
- /// Initialize the test client with routes, handlers, and server config.
92
- ///
93
- /// # Arguments
94
- ///
95
- /// * `ruby` - Ruby VM reference
96
- /// * `this` - The wrapped NativeTestClient instance
97
- /// * `routes_json` - JSON string containing route metadata
98
- /// * `handlers` - Ruby Hash mapping handler_name => Proc
99
- /// * `config_value` - Ruby ServerConfig object
100
- /// * `ws_handlers` - Ruby Hash of WebSocket handlers (optional)
101
- /// * `sse_producers` - Ruby Hash of SSE producers (optional)
102
- pub fn initialize(
103
- ruby: &Ruby,
104
- this: &Self,
105
- routes_json: String,
106
- handlers: Value,
107
- config_value: Value,
108
- ws_handlers: Value,
109
- sse_producers: Value,
110
- ) -> Result<(), Error> {
111
- trace_step("initialize:start");
112
- let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
113
- .map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {err}")))?;
114
-
115
- let handlers_hash = RHash::from_value(handlers).ok_or_else(|| {
116
- Error::new(
117
- ruby.exception_arg_error(),
118
- "handlers parameter must be a Hash of handler_name => Proc",
119
- )
120
- })?;
121
-
122
- let json_module = ruby
123
- .class_object()
124
- .const_get("JSON")
125
- .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
126
-
127
- let server_config = crate::config::extract_server_config(ruby, config_value)?;
128
-
129
- let schema_registry = spikard_http::SchemaRegistry::new();
130
- let mut prepared_routes = Vec::with_capacity(metadata.len());
131
- let mut handler_refs = Vec::with_capacity(metadata.len());
132
- let mut route_metadata_vec = Vec::with_capacity(metadata.len());
133
-
134
- for meta in metadata.clone() {
135
- let handler_value = crate::conversion::fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
136
- let route = Route::from_metadata(meta.clone(), &schema_registry)
137
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build route: {err}")))?;
138
-
139
- let handler = RubyHandler::new(&route, handler_value, json_module)?;
140
- prepared_routes.push((route, Arc::new(handler.clone()) as Arc<dyn spikard_http::Handler>));
141
- handler_refs.push(handler);
142
- route_metadata_vec.push(meta);
143
- }
144
-
145
- trace_step("initialize:build_router");
146
- let mut router = spikard_http::server::build_router_with_handlers_and_config(
147
- prepared_routes,
148
- server_config,
149
- route_metadata_vec,
150
- )
151
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build router: {err}")))?;
152
-
153
- let mut ws_endpoints = Vec::new();
154
- if !ws_handlers.is_nil() {
155
- trace_step("initialize:ws_handlers");
156
- let ws_hash = RHash::from_value(ws_handlers)
157
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
158
-
159
- ws_hash.foreach(
160
- |path: String, factory: Value| -> Result<magnus::r_hash::ForEach, Error> {
161
- let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
162
- Error::new(
163
- ruby.exception_runtime_error(),
164
- format!("Failed to create WebSocket handler: {}", e),
165
- )
166
- })?;
167
-
168
- let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
169
-
170
- ws_endpoints.push((path, ws_state));
171
-
172
- Ok(magnus::r_hash::ForEach::Continue)
173
- },
174
- )?;
175
- }
176
-
177
- let mut sse_endpoints = Vec::new();
178
- if !sse_producers.is_nil() {
179
- trace_step("initialize:sse_producers");
180
- let sse_hash = RHash::from_value(sse_producers)
181
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "SSE producers must be a Hash"))?;
182
-
183
- sse_hash.foreach(
184
- |path: String, factory: Value| -> Result<magnus::r_hash::ForEach, Error> {
185
- let producer_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
186
- Error::new(
187
- ruby.exception_runtime_error(),
188
- format!("Failed to create SSE producer: {}", e),
189
- )
190
- })?;
191
-
192
- let sse_state = crate::sse::create_sse_state(ruby, producer_instance)?;
193
-
194
- sse_endpoints.push((path, sse_state));
195
-
196
- Ok(magnus::r_hash::ForEach::Continue)
197
- },
198
- )?;
199
- }
200
-
201
- use axum::routing::get;
202
- for (path, ws_state) in ws_endpoints {
203
- router = router.route(
204
- &path,
205
- get(spikard_http::websocket_handler::<crate::websocket::RubyWebSocketHandler>).with_state(ws_state),
206
- );
207
- }
208
-
209
- for (path, sse_state) in sse_endpoints {
210
- router = router.route(
211
- &path,
212
- get(spikard_http::sse_handler::<crate::sse::RubySseEventProducer>).with_state(sse_state),
213
- );
214
- }
215
-
216
- trace_step("initialize:test_server_http");
217
- let timeout_duration = test_server_timeout();
218
- let http_server = init_test_server(router.clone(), timeout_duration, "test server", ruby)?;
219
-
220
- trace_step("initialize:test_server_ws");
221
- let ws_config = TestServerConfig {
222
- transport: Some(Transport::HttpRandomPort),
223
- ..Default::default()
224
- };
225
- let transport_server =
226
- init_test_server_with_config(router, ws_config, timeout_duration, "WebSocket transport server", ruby)?;
227
-
228
- trace_step("initialize:done");
229
- *this.inner.borrow_mut() = Some(ClientInner {
230
- http_server: Arc::new(http_server),
231
- transport_server: Arc::new(transport_server),
232
- handlers: handler_refs,
233
- });
234
-
235
- Ok(())
236
- }
237
-
238
- /// Execute an HTTP request against the test server.
239
- ///
240
- /// # Arguments
241
- ///
242
- /// * `ruby` - Ruby VM reference
243
- /// * `this` - The wrapped NativeTestClient instance
244
- /// * `method` - HTTP method (GET, POST, etc.)
245
- /// * `path` - URL path
246
- /// * `options` - Ruby Hash with query, headers, cookies, body, etc.
247
- pub fn request(ruby: &Ruby, this: &Self, method: String, path: String, options: Value) -> Result<Value, Error> {
248
- let inner_borrow = this.inner.borrow();
249
- let inner = inner_borrow
250
- .as_ref()
251
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
252
- let method_upper = method.to_ascii_uppercase();
253
- let http_method = Method::from_bytes(method_upper.as_bytes()).map_err(|err| {
254
- Error::new(
255
- ruby.exception_arg_error(),
256
- format!("Unsupported method {method_upper}: {err}"),
257
- )
258
- })?;
259
-
260
- let request_config = parse_request_config(ruby, options)?;
261
-
262
- let runtime = crate::server::global_runtime(ruby)?;
263
- let server = inner.http_server.clone();
264
- let path_value = path.clone();
265
- let request_config_value = request_config;
266
- let response = crate::call_without_gvl!(
267
- block_on_request,
268
- args: (
269
- runtime, &tokio::runtime::Runtime,
270
- server, Arc<TestServer>,
271
- http_method, Method,
272
- path_value, String,
273
- request_config_value, RequestConfig
274
- ),
275
- return_type: Result<TestResponseData, NativeRequestError>
276
- )
277
- .map_err(|err| {
278
- Error::new(
279
- ruby.exception_runtime_error(),
280
- format!("Request failed for {method_upper} {path}: {}", err.0),
281
- )
282
- })?;
283
-
284
- response_to_ruby(ruby, response)
285
- }
286
-
287
- /// Close the test client and clean up resources.
288
- pub fn close(&self) -> Result<(), Error> {
289
- *self.inner.borrow_mut() = None;
290
- Ok(())
291
- }
292
-
293
- /// Connect to a WebSocket endpoint on the test server.
294
- pub fn websocket(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
295
- let inner_borrow = this.inner.borrow();
296
- let inner = inner_borrow
297
- .as_ref()
298
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
299
-
300
- let server = Arc::clone(&inner.transport_server);
301
-
302
- drop(inner_borrow);
303
-
304
- let timeout_duration = websocket_timeout();
305
- let ws = crate::call_without_gvl!(
306
- block_on_websocket_connect,
307
- args: (
308
- server, Arc<TestServer>,
309
- path, String,
310
- timeout_duration, Duration
311
- ),
312
- return_type: Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError>
313
- )
314
- .map_err(|err| match err {
315
- WebSocketConnectError::Timeout => Error::new(
316
- ruby.exception_runtime_error(),
317
- format!(
318
- "WebSocket connect timed out after {}ms",
319
- timeout_duration.as_millis()
320
- ),
321
- ),
322
- WebSocketConnectError::Other(message) => Error::new(
323
- ruby.exception_runtime_error(),
324
- format!("WebSocket connect failed: {}", message),
325
- ),
326
- })?;
327
-
328
- let ws_conn = crate::testing::websocket::WebSocketTestConnection::new(ws);
329
- Ok(ruby.obj_wrap(ws_conn).as_value())
330
- }
331
-
332
- /// Connect to an SSE endpoint on the test server.
333
- pub fn sse(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
334
- let inner_borrow = this.inner.borrow();
335
- let inner = inner_borrow
336
- .as_ref()
337
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
338
-
339
- let runtime = crate::server::global_runtime(ruby)?;
340
- let server = inner.http_server.clone();
341
- let http_method = Method::GET;
342
- let request_config = RequestConfig {
343
- query: None,
344
- headers: HashMap::new(),
345
- cookies: HashMap::new(),
346
- body: None,
347
- };
348
- let response = crate::call_without_gvl!(
349
- block_on_request,
350
- args: (
351
- runtime, &tokio::runtime::Runtime,
352
- server, Arc<TestServer>,
353
- http_method, Method,
354
- path, String,
355
- request_config, RequestConfig
356
- ),
357
- return_type: Result<TestResponseData, NativeRequestError>
358
- )
359
- .map_err(|err| {
360
- Error::new(
361
- ruby.exception_runtime_error(),
362
- format!("SSE request failed: {}", err.0),
363
- )
364
- })?;
365
-
366
- let body = response.body_text.unwrap_or_default().into_bytes();
367
- let snapshot = ResponseSnapshot {
368
- status: response.status,
369
- headers: response.headers,
370
- body,
371
- };
372
-
373
- crate::testing::sse::sse_stream_from_response(ruby, &snapshot)
374
- }
375
-
376
- /// GC mark hook so Ruby keeps handler closures alive.
377
- #[allow(dead_code)]
378
- pub fn mark(&self, marker: &Marker) {
379
- let inner_ref = self.inner.borrow();
380
- if let Some(inner) = inner_ref.as_ref() {
381
- for handler in &inner.handlers {
382
- handler.mark(marker);
383
- }
384
- }
385
- }
386
- }
387
-
388
- fn websocket_timeout() -> Duration {
389
- const DEFAULT_TIMEOUT_MS: u64 = 30_000;
390
- let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
391
- .ok()
392
- .and_then(|value| value.parse::<u64>().ok())
393
- .unwrap_or(DEFAULT_TIMEOUT_MS);
394
- Duration::from_millis(timeout_ms)
395
- }
396
-
397
- fn block_on_request(
398
- runtime: &tokio::runtime::Runtime,
399
- server: Arc<TestServer>,
400
- method: Method,
401
- path: String,
402
- config: RequestConfig,
403
- ) -> Result<TestResponseData, NativeRequestError> {
404
- runtime.block_on(execute_request(server, method, path, config))
405
- }
406
-
407
- fn block_on_websocket_connect(
408
- server: Arc<TestServer>,
409
- path: String,
410
- timeout_duration: Duration,
411
- ) -> Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError> {
412
- let url = server
413
- .server_url(&path)
414
- .map_err(|err| WebSocketConnectError::Other(err.to_string()))?;
415
- let ws_url = to_ws_url(url)?;
416
-
417
- match crate::testing::websocket::WebSocketConnection::connect(ws_url, timeout_duration) {
418
- Ok(ws) => Ok(ws),
419
- Err(crate::testing::websocket::WebSocketIoError::Timeout) => Err(WebSocketConnectError::Timeout),
420
- Err(err) => Err(WebSocketConnectError::Other(format!("{:?}", err))),
421
- }
422
- }
423
-
424
- fn to_ws_url(mut url: Url) -> Result<Url, WebSocketConnectError> {
425
- let scheme = match url.scheme() {
426
- "https" => "wss",
427
- _ => "ws",
428
- };
429
- url.set_scheme(scheme)
430
- .map_err(|_| WebSocketConnectError::Other("Failed to set WebSocket scheme".to_string()))?;
431
- Ok(url)
432
- }
433
-
434
- fn test_server_timeout() -> Duration {
435
- const DEFAULT_TIMEOUT_MS: u64 = 30_000;
436
- let timeout_ms = std::env::var("SPIKARD_RB_TESTSERVER_TIMEOUT_MS")
437
- .ok()
438
- .and_then(|value| value.parse::<u64>().ok())
439
- .unwrap_or(DEFAULT_TIMEOUT_MS);
440
- Duration::from_millis(timeout_ms)
441
- }
442
-
443
- fn trace_step(message: &str) {
444
- if std::env::var("SPIKARD_RB_TEST_TRACE").ok().as_deref() == Some("1") {
445
- eprintln!("[spikard-rb-test] {}", message);
446
- }
447
- }
448
-
449
- fn init_test_server(router: Router, _timeout: Duration, label: &str, ruby: &Ruby) -> Result<TestServer, Error> {
450
- let runtime = crate::server::global_runtime(ruby)?;
451
- let _guard = runtime.enter();
452
- TestServer::new(router).map_err(|err| {
453
- Error::new(
454
- ruby.exception_runtime_error(),
455
- format!("Failed to initialise {label}: {err}"),
456
- )
457
- })
458
- }
459
-
460
- fn init_test_server_with_config(
461
- router: Router,
462
- config: TestServerConfig,
463
- _timeout: Duration,
464
- label: &str,
465
- ruby: &Ruby,
466
- ) -> Result<TestServer, Error> {
467
- let runtime = crate::server::global_runtime(ruby)?;
468
- let _guard = runtime.enter();
469
- TestServer::new_with_config(router, config).map_err(|err| {
470
- Error::new(
471
- ruby.exception_runtime_error(),
472
- format!("Failed to initialise {label}: {err}"),
473
- )
474
- })
475
- }
476
-
477
- /// Execute an HTTP request against a test server.
478
- ///
479
- /// Handles method routing, query params, headers, cookies, and various body formats.
480
- pub async fn execute_request(
481
- server: Arc<TestServer>,
482
- method: Method,
483
- path: String,
484
- config: RequestConfig,
485
- ) -> Result<TestResponseData, NativeRequestError> {
486
- let mut request = match method {
487
- Method::GET => server.get(&path),
488
- Method::POST => server.post(&path),
489
- Method::PUT => server.put(&path),
490
- Method::PATCH => server.patch(&path),
491
- Method::DELETE => server.delete(&path),
492
- Method::HEAD => server.method(Method::HEAD, &path),
493
- Method::OPTIONS => server.method(Method::OPTIONS, &path),
494
- Method::TRACE => server.method(Method::TRACE, &path),
495
- other => return Err(NativeRequestError(format!("Unsupported HTTP method {other}"))),
496
- };
497
-
498
- if let Some(query) = config.query {
499
- request = request.add_query_params(&query);
500
- }
501
-
502
- for (name, value) in config.headers {
503
- request = request.add_header(name.as_str(), value.as_str());
504
- }
505
-
506
- for (name, value) in config.cookies {
507
- request = request.add_cookie(Cookie::new(name, value));
508
- }
509
-
510
- if let Some(body) = config.body {
511
- match body {
512
- RequestBody::Json(json_value) => {
513
- request = request.json(&json_value);
514
- }
515
- RequestBody::Form(form_value) => {
516
- let encoded = encode_urlencoded_body(&form_value)
517
- .map_err(|err| NativeRequestError(format!("Failed to encode form body: {err}")))?;
518
- request = request
519
- .content_type("application/x-www-form-urlencoded")
520
- .bytes(Bytes::from(encoded));
521
- }
522
- RequestBody::Raw(raw) => {
523
- request = request.bytes(Bytes::from(raw));
524
- }
525
- RequestBody::Multipart { form_data, files } => {
526
- let (multipart_body, boundary) = build_multipart_body(&form_data, &files);
527
- request = request
528
- .content_type(&format!("multipart/form-data; boundary={}", boundary))
529
- .bytes(Bytes::from(multipart_body));
530
- }
531
- }
532
- }
533
-
534
- let response = request.await;
535
- let snapshot = snapshot_response(response).await.map_err(snapshot_err_to_native)?;
536
- let body_text = if snapshot.body.is_empty() {
537
- None
538
- } else {
539
- Some(String::from_utf8_lossy(&snapshot.body).into_owned())
540
- };
541
-
542
- Ok(TestResponseData {
543
- status: snapshot.status,
544
- headers: snapshot.headers,
545
- body_text,
546
- })
547
- }
548
-
549
- fn snapshot_err_to_native(err: SnapshotError) -> NativeRequestError {
550
- NativeRequestError(err.to_string())
551
- }
1
+ //! Native Ruby test client for HTTP testing.
2
+ //!
3
+ //! This module implements `NativeTestClient`, a wrapped Ruby class that provides
4
+ //! HTTP testing capabilities against a Spikard server. It manages test servers
5
+ //! for both HTTP and WebSocket/SSE transports.
6
+
7
+ #![allow(dead_code)]
8
+
9
+ use axum::Router;
10
+ use axum::http::Method;
11
+ use axum_test::{TestServer, TestServerConfig, Transport};
12
+ use bytes::Bytes;
13
+ use cookie::Cookie;
14
+ use magnus::prelude::*;
15
+ use magnus::{Error, RHash, Ruby, Value, gc::Marker};
16
+ use serde_json::Value as JsonValue;
17
+ use spikard_http::testing::{
18
+ MultipartFilePart, ResponseSnapshot, SnapshotError, build_multipart_body, encode_urlencoded_body, snapshot_response,
19
+ };
20
+ use spikard_http::{Route, RouteMetadata};
21
+ use std::cell::RefCell;
22
+ use std::collections::HashMap;
23
+ use std::sync::Arc;
24
+ use std::time::Duration;
25
+ use url::Url;
26
+
27
+ use crate::conversion::{parse_request_config, response_to_ruby};
28
+ use crate::handler::RubyHandler;
29
+
30
+ /// Request configuration built from Ruby options hash.
31
+ pub struct RequestConfig {
32
+ pub query: Option<JsonValue>,
33
+ pub headers: HashMap<String, String>,
34
+ pub cookies: HashMap<String, String>,
35
+ pub body: Option<RequestBody>,
36
+ }
37
+
38
+ /// HTTP request body variants.
39
+ pub enum RequestBody {
40
+ Json(JsonValue),
41
+ Form(JsonValue),
42
+ Raw(String),
43
+ Multipart {
44
+ form_data: Vec<(String, String)>,
45
+ files: Vec<MultipartFilePart>,
46
+ },
47
+ }
48
+
49
+ /// Snapshot of an HTTP response.
50
+ pub struct TestResponseData {
51
+ pub status: u16,
52
+ pub headers: HashMap<String, String>,
53
+ pub body_text: Option<String>,
54
+ }
55
+
56
+ /// Error wrapper for native request failures.
57
+ #[derive(Debug)]
58
+ pub struct NativeRequestError(pub String);
59
+
60
+ #[derive(Debug)]
61
+ enum WebSocketConnectError {
62
+ Timeout,
63
+ Other(String),
64
+ }
65
+
66
+ /// Inner client state containing the test servers and handlers.
67
+ pub struct ClientInner {
68
+ pub http_server: Arc<TestServer>,
69
+ pub transport_server: Arc<TestServer>,
70
+ /// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
71
+ #[allow(dead_code)]
72
+ pub handlers: Vec<RubyHandler>,
73
+ }
74
+
75
+ /// Native Ruby TestClient wrapper for integration testing.
76
+ ///
77
+ /// Wraps an optional `ClientInner` that holds the HTTP test servers
78
+ /// and keeps handler references alive for Ruby's garbage collector.
79
+ #[derive(Default)]
80
+ #[magnus::wrap(class = "Spikard::Native::TestClient", free_immediately, mark)]
81
+ pub struct NativeTestClient {
82
+ pub inner: RefCell<Option<ClientInner>>,
83
+ }
84
+
85
+ impl NativeTestClient {
86
+ /// Initialize the test client with routes, handlers, and server config.
87
+ ///
88
+ /// # Arguments
89
+ ///
90
+ /// * `ruby` - Ruby VM reference
91
+ /// * `this` - The wrapped NativeTestClient instance
92
+ /// * `routes_json` - JSON string containing route metadata
93
+ /// * `handlers` - Ruby Hash mapping handler_name => Proc
94
+ /// * `config_value` - Ruby ServerConfig object
95
+ /// * `ws_handlers` - Ruby Hash of WebSocket handlers (optional)
96
+ /// * `sse_producers` - Ruby Hash of SSE producers (optional)
97
+ pub fn initialize(
98
+ ruby: &Ruby,
99
+ this: &Self,
100
+ routes_json: String,
101
+ handlers: Value,
102
+ config_value: Value,
103
+ ws_handlers: Value,
104
+ sse_producers: Value,
105
+ ) -> Result<(), Error> {
106
+ trace_step("initialize:start");
107
+ let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
108
+ .map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {err}")))?;
109
+
110
+ let handlers_hash = RHash::from_value(handlers).ok_or_else(|| {
111
+ Error::new(
112
+ ruby.exception_arg_error(),
113
+ "handlers parameter must be a Hash of handler_name => Proc",
114
+ )
115
+ })?;
116
+
117
+ let json_module = ruby
118
+ .class_object()
119
+ .const_get("JSON")
120
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
121
+
122
+ let server_config = crate::config::extract_server_config(ruby, config_value)?;
123
+
124
+ let schema_registry = spikard_http::SchemaRegistry::new();
125
+ let mut prepared_routes = Vec::with_capacity(metadata.len());
126
+ let mut handler_refs = Vec::with_capacity(metadata.len());
127
+ let mut route_metadata_vec = Vec::with_capacity(metadata.len());
128
+
129
+ for meta in metadata.clone() {
130
+ let handler_value = crate::conversion::fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
131
+ let route = Route::from_metadata(meta.clone(), &schema_registry)
132
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build route: {err}")))?;
133
+
134
+ let handler = RubyHandler::new(&route, handler_value, json_module)?;
135
+ prepared_routes.push((route, Arc::new(handler.clone()) as Arc<dyn spikard_http::Handler>));
136
+ handler_refs.push(handler);
137
+ route_metadata_vec.push(meta);
138
+ }
139
+
140
+ trace_step("initialize:build_router");
141
+ let mut router = spikard_http::server::build_router_with_handlers_and_config(
142
+ prepared_routes,
143
+ server_config,
144
+ route_metadata_vec,
145
+ )
146
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build router: {err}")))?;
147
+
148
+ let mut ws_endpoints = Vec::new();
149
+ if !ws_handlers.is_nil() {
150
+ trace_step("initialize:ws_handlers");
151
+ let ws_hash = RHash::from_value(ws_handlers)
152
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
153
+
154
+ ws_hash.foreach(
155
+ |path: String, factory: Value| -> Result<magnus::r_hash::ForEach, Error> {
156
+ let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
157
+ Error::new(
158
+ ruby.exception_runtime_error(),
159
+ format!("Failed to create WebSocket handler: {}", e),
160
+ )
161
+ })?;
162
+
163
+ let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
164
+
165
+ ws_endpoints.push((path, ws_state));
166
+
167
+ Ok(magnus::r_hash::ForEach::Continue)
168
+ },
169
+ )?;
170
+ }
171
+
172
+ let mut sse_endpoints = Vec::new();
173
+ if !sse_producers.is_nil() {
174
+ trace_step("initialize:sse_producers");
175
+ let sse_hash = RHash::from_value(sse_producers)
176
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "SSE producers must be a Hash"))?;
177
+
178
+ sse_hash.foreach(
179
+ |path: String, factory: Value| -> Result<magnus::r_hash::ForEach, Error> {
180
+ let producer_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
181
+ Error::new(
182
+ ruby.exception_runtime_error(),
183
+ format!("Failed to create SSE producer: {}", e),
184
+ )
185
+ })?;
186
+
187
+ let sse_state = crate::sse::create_sse_state(ruby, producer_instance)?;
188
+
189
+ sse_endpoints.push((path, sse_state));
190
+
191
+ Ok(magnus::r_hash::ForEach::Continue)
192
+ },
193
+ )?;
194
+ }
195
+
196
+ use axum::routing::get;
197
+ for (path, ws_state) in ws_endpoints {
198
+ router = router.route(
199
+ &path,
200
+ get(spikard_http::websocket_handler::<crate::websocket::RubyWebSocketHandler>).with_state(ws_state),
201
+ );
202
+ }
203
+
204
+ for (path, sse_state) in sse_endpoints {
205
+ router = router.route(
206
+ &path,
207
+ get(spikard_http::sse_handler::<crate::sse::RubySseEventProducer>).with_state(sse_state),
208
+ );
209
+ }
210
+
211
+ trace_step("initialize:test_server_http");
212
+ let timeout_duration = test_server_timeout();
213
+ let http_server = init_test_server(router.clone(), timeout_duration, "test server", ruby)?;
214
+
215
+ trace_step("initialize:test_server_ws");
216
+ let ws_config = TestServerConfig {
217
+ transport: Some(Transport::HttpRandomPort),
218
+ ..Default::default()
219
+ };
220
+ let transport_server =
221
+ init_test_server_with_config(router, ws_config, timeout_duration, "WebSocket transport server", ruby)?;
222
+
223
+ trace_step("initialize:done");
224
+ *this.inner.borrow_mut() = Some(ClientInner {
225
+ http_server: Arc::new(http_server),
226
+ transport_server: Arc::new(transport_server),
227
+ handlers: handler_refs,
228
+ });
229
+
230
+ Ok(())
231
+ }
232
+
233
+ /// Execute an HTTP request against the test server.
234
+ ///
235
+ /// # Arguments
236
+ ///
237
+ /// * `ruby` - Ruby VM reference
238
+ /// * `this` - The wrapped NativeTestClient instance
239
+ /// * `method` - HTTP method (GET, POST, etc.)
240
+ /// * `path` - URL path
241
+ /// * `options` - Ruby Hash with query, headers, cookies, body, etc.
242
+ pub fn request(ruby: &Ruby, this: &Self, method: String, path: String, options: Value) -> Result<Value, Error> {
243
+ let inner_borrow = this.inner.borrow();
244
+ let inner = inner_borrow
245
+ .as_ref()
246
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
247
+ let method_upper = method.to_ascii_uppercase();
248
+ let http_method = Method::from_bytes(method_upper.as_bytes()).map_err(|err| {
249
+ Error::new(
250
+ ruby.exception_arg_error(),
251
+ format!("Unsupported method {method_upper}: {err}"),
252
+ )
253
+ })?;
254
+
255
+ let request_config = parse_request_config(ruby, options)?;
256
+
257
+ let runtime = crate::server::global_runtime(ruby)?;
258
+ let server = inner.http_server.clone();
259
+ let path_value = path.clone();
260
+ let request_config_value = request_config;
261
+ let response = crate::call_without_gvl!(
262
+ block_on_request,
263
+ args: (
264
+ runtime, &tokio::runtime::Runtime,
265
+ server, Arc<TestServer>,
266
+ http_method, Method,
267
+ path_value, String,
268
+ request_config_value, RequestConfig
269
+ ),
270
+ return_type: Result<TestResponseData, NativeRequestError>
271
+ )
272
+ .map_err(|err| {
273
+ Error::new(
274
+ ruby.exception_runtime_error(),
275
+ format!("Request failed for {method_upper} {path}: {}", err.0),
276
+ )
277
+ })?;
278
+
279
+ response_to_ruby(ruby, response)
280
+ }
281
+
282
+ /// Close the test client and clean up resources.
283
+ pub fn close(&self) -> Result<(), Error> {
284
+ *self.inner.borrow_mut() = None;
285
+ Ok(())
286
+ }
287
+
288
+ /// Connect to a WebSocket endpoint on the test server.
289
+ pub fn websocket(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
290
+ let inner_borrow = this.inner.borrow();
291
+ let inner = inner_borrow
292
+ .as_ref()
293
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
294
+
295
+ let server = Arc::clone(&inner.transport_server);
296
+
297
+ drop(inner_borrow);
298
+
299
+ let timeout_duration = websocket_timeout();
300
+ let ws = crate::call_without_gvl!(
301
+ block_on_websocket_connect,
302
+ args: (
303
+ server, Arc<TestServer>,
304
+ path, String,
305
+ timeout_duration, Duration
306
+ ),
307
+ return_type: Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError>
308
+ )
309
+ .map_err(|err| match err {
310
+ WebSocketConnectError::Timeout => Error::new(
311
+ ruby.exception_runtime_error(),
312
+ format!("WebSocket connect timed out after {}ms", timeout_duration.as_millis()),
313
+ ),
314
+ WebSocketConnectError::Other(message) => Error::new(
315
+ ruby.exception_runtime_error(),
316
+ format!("WebSocket connect failed: {}", message),
317
+ ),
318
+ })?;
319
+
320
+ let ws_conn = crate::testing::websocket::WebSocketTestConnection::new(ws);
321
+ Ok(ruby.obj_wrap(ws_conn).as_value())
322
+ }
323
+
324
+ /// Connect to an SSE endpoint on the test server.
325
+ pub fn sse(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
326
+ let inner_borrow = this.inner.borrow();
327
+ let inner = inner_borrow
328
+ .as_ref()
329
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
330
+
331
+ let runtime = crate::server::global_runtime(ruby)?;
332
+ let server = inner.http_server.clone();
333
+ let http_method = Method::GET;
334
+ let request_config = RequestConfig {
335
+ query: None,
336
+ headers: HashMap::new(),
337
+ cookies: HashMap::new(),
338
+ body: None,
339
+ };
340
+ let response = crate::call_without_gvl!(
341
+ block_on_request,
342
+ args: (
343
+ runtime, &tokio::runtime::Runtime,
344
+ server, Arc<TestServer>,
345
+ http_method, Method,
346
+ path, String,
347
+ request_config, RequestConfig
348
+ ),
349
+ return_type: Result<TestResponseData, NativeRequestError>
350
+ )
351
+ .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", err.0)))?;
352
+
353
+ let body = response.body_text.unwrap_or_default().into_bytes();
354
+ let snapshot = ResponseSnapshot {
355
+ status: response.status,
356
+ headers: response.headers,
357
+ body,
358
+ };
359
+
360
+ crate::testing::sse::sse_stream_from_response(ruby, &snapshot)
361
+ }
362
+
363
+ /// GC mark hook so Ruby keeps handler closures alive.
364
+ #[allow(dead_code)]
365
+ pub fn mark(&self, marker: &Marker) {
366
+ let inner_ref = self.inner.borrow();
367
+ if let Some(inner) = inner_ref.as_ref() {
368
+ for handler in &inner.handlers {
369
+ handler.mark(marker);
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ fn websocket_timeout() -> Duration {
376
+ const DEFAULT_TIMEOUT_MS: u64 = 30_000;
377
+ let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
378
+ .ok()
379
+ .and_then(|value| value.parse::<u64>().ok())
380
+ .unwrap_or(DEFAULT_TIMEOUT_MS);
381
+ Duration::from_millis(timeout_ms)
382
+ }
383
+
384
+ fn block_on_request(
385
+ runtime: &tokio::runtime::Runtime,
386
+ server: Arc<TestServer>,
387
+ method: Method,
388
+ path: String,
389
+ config: RequestConfig,
390
+ ) -> Result<TestResponseData, NativeRequestError> {
391
+ runtime.block_on(execute_request(server, method, path, config))
392
+ }
393
+
394
+ fn block_on_websocket_connect(
395
+ server: Arc<TestServer>,
396
+ path: String,
397
+ timeout_duration: Duration,
398
+ ) -> Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError> {
399
+ let url = server
400
+ .server_url(&path)
401
+ .map_err(|err| WebSocketConnectError::Other(err.to_string()))?;
402
+ let ws_url = to_ws_url(url)?;
403
+
404
+ match crate::testing::websocket::WebSocketConnection::connect(ws_url, timeout_duration) {
405
+ Ok(ws) => Ok(ws),
406
+ Err(crate::testing::websocket::WebSocketIoError::Timeout) => Err(WebSocketConnectError::Timeout),
407
+ Err(err) => Err(WebSocketConnectError::Other(format!("{:?}", err))),
408
+ }
409
+ }
410
+
411
+ fn to_ws_url(mut url: Url) -> Result<Url, WebSocketConnectError> {
412
+ let scheme = match url.scheme() {
413
+ "https" => "wss",
414
+ _ => "ws",
415
+ };
416
+ url.set_scheme(scheme)
417
+ .map_err(|_| WebSocketConnectError::Other("Failed to set WebSocket scheme".to_string()))?;
418
+ Ok(url)
419
+ }
420
+
421
+ fn test_server_timeout() -> Duration {
422
+ const DEFAULT_TIMEOUT_MS: u64 = 30_000;
423
+ let timeout_ms = std::env::var("SPIKARD_RB_TESTSERVER_TIMEOUT_MS")
424
+ .ok()
425
+ .and_then(|value| value.parse::<u64>().ok())
426
+ .unwrap_or(DEFAULT_TIMEOUT_MS);
427
+ Duration::from_millis(timeout_ms)
428
+ }
429
+
430
+ fn trace_step(message: &str) {
431
+ if std::env::var("SPIKARD_RB_TEST_TRACE").ok().as_deref() == Some("1") {
432
+ eprintln!("[spikard-rb-test] {}", message);
433
+ }
434
+ }
435
+
436
+ fn init_test_server(router: Router, _timeout: Duration, label: &str, ruby: &Ruby) -> Result<TestServer, Error> {
437
+ let runtime = crate::server::global_runtime(ruby)?;
438
+ let _guard = runtime.enter();
439
+ TestServer::new(router).map_err(|err| {
440
+ Error::new(
441
+ ruby.exception_runtime_error(),
442
+ format!("Failed to initialise {label}: {err}"),
443
+ )
444
+ })
445
+ }
446
+
447
+ fn init_test_server_with_config(
448
+ router: Router,
449
+ config: TestServerConfig,
450
+ _timeout: Duration,
451
+ label: &str,
452
+ ruby: &Ruby,
453
+ ) -> Result<TestServer, Error> {
454
+ let runtime = crate::server::global_runtime(ruby)?;
455
+ let _guard = runtime.enter();
456
+ TestServer::new_with_config(router, config).map_err(|err| {
457
+ Error::new(
458
+ ruby.exception_runtime_error(),
459
+ format!("Failed to initialise {label}: {err}"),
460
+ )
461
+ })
462
+ }
463
+
464
+ /// Execute an HTTP request against a test server.
465
+ ///
466
+ /// Handles method routing, query params, headers, cookies, and various body formats.
467
+ pub async fn execute_request(
468
+ server: Arc<TestServer>,
469
+ method: Method,
470
+ path: String,
471
+ config: RequestConfig,
472
+ ) -> Result<TestResponseData, NativeRequestError> {
473
+ let mut request = match method {
474
+ Method::GET => server.get(&path),
475
+ Method::POST => server.post(&path),
476
+ Method::PUT => server.put(&path),
477
+ Method::PATCH => server.patch(&path),
478
+ Method::DELETE => server.delete(&path),
479
+ Method::HEAD => server.method(Method::HEAD, &path),
480
+ Method::OPTIONS => server.method(Method::OPTIONS, &path),
481
+ Method::TRACE => server.method(Method::TRACE, &path),
482
+ other => return Err(NativeRequestError(format!("Unsupported HTTP method {other}"))),
483
+ };
484
+
485
+ if let Some(query) = config.query {
486
+ request = request.add_query_params(&query);
487
+ }
488
+
489
+ for (name, value) in config.headers {
490
+ request = request.add_header(name.as_str(), value.as_str());
491
+ }
492
+
493
+ for (name, value) in config.cookies {
494
+ request = request.add_cookie(Cookie::new(name, value));
495
+ }
496
+
497
+ if let Some(body) = config.body {
498
+ match body {
499
+ RequestBody::Json(json_value) => {
500
+ request = request.json(&json_value);
501
+ }
502
+ RequestBody::Form(form_value) => {
503
+ let encoded = encode_urlencoded_body(&form_value)
504
+ .map_err(|err| NativeRequestError(format!("Failed to encode form body: {err}")))?;
505
+ request = request
506
+ .content_type("application/x-www-form-urlencoded")
507
+ .bytes(Bytes::from(encoded));
508
+ }
509
+ RequestBody::Raw(raw) => {
510
+ request = request.bytes(Bytes::from(raw));
511
+ }
512
+ RequestBody::Multipart { form_data, files } => {
513
+ let (multipart_body, boundary) = build_multipart_body(&form_data, &files);
514
+ request = request
515
+ .content_type(&format!("multipart/form-data; boundary={}", boundary))
516
+ .bytes(Bytes::from(multipart_body));
517
+ }
518
+ }
519
+ }
520
+
521
+ let response = request.await;
522
+ let snapshot = snapshot_response(response).await.map_err(snapshot_err_to_native)?;
523
+ let body_text = if snapshot.body.is_empty() {
524
+ None
525
+ } else {
526
+ Some(String::from_utf8_lossy(&snapshot.body).into_owned())
527
+ };
528
+
529
+ Ok(TestResponseData {
530
+ status: snapshot.status,
531
+ headers: snapshot.headers,
532
+ body_text,
533
+ })
534
+ }
535
+
536
+ fn snapshot_err_to_native(err: SnapshotError) -> NativeRequestError {
537
+ NativeRequestError(err.to_string())
538
+ }