spikard 0.6.2 → 0.7.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +1 -1
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -0,0 +1,342 @@
1
+ //! Native Request object for Ruby handlers.
2
+ //!
3
+ //! Ruby benchmarks frequently access only a subset of request fields. Building a full
4
+ //! Ruby Hash for every request eagerly converts headers/cookies/query/etc even when
5
+ //! unused. This module provides a native `Spikard::Native::Request` that lazily
6
+ //! materialises Ruby values on demand and caches them for subsequent access.
7
+
8
+ #![deny(clippy::unwrap_used)]
9
+
10
+ use bytes::Bytes;
11
+ use magnus::prelude::*;
12
+ use magnus::value::InnerValue;
13
+ use magnus::value::LazyId;
14
+ use magnus::value::Opaque;
15
+ use magnus::{Error, RHash, RString, Ruby, Symbol, Value, gc::Marker};
16
+ use serde_json::Value as JsonValue;
17
+ use spikard_http::RequestData;
18
+ use std::cell::RefCell;
19
+ use std::collections::HashMap;
20
+ use std::sync::Arc;
21
+
22
+ use crate::conversion::{map_to_ruby_hash, multimap_to_ruby_hash};
23
+ use crate::metadata::json_to_ruby;
24
+
25
+ #[derive(Default)]
26
+ struct RequestCache {
27
+ method: Option<Opaque<Value>>,
28
+ path: Option<Opaque<Value>>,
29
+ path_params: Option<Opaque<Value>>,
30
+ query: Option<Opaque<Value>>,
31
+ raw_query: Option<Opaque<Value>>,
32
+ headers: Option<Opaque<Value>>,
33
+ cookies: Option<Opaque<Value>>,
34
+ body: Option<Opaque<Value>>,
35
+ raw_body: Option<Opaque<Value>>,
36
+ params: Option<Opaque<Value>>,
37
+ to_h: Option<Opaque<Value>>,
38
+ }
39
+
40
+ #[magnus::wrap(class = "Spikard::Native::Request", free_immediately, mark)]
41
+ pub(crate) struct NativeRequest {
42
+ method: String,
43
+ path: String,
44
+ path_params: Arc<HashMap<String, String>>,
45
+ query_params: JsonValue,
46
+ raw_query_params: Arc<HashMap<String, Vec<String>>>,
47
+ body: JsonValue,
48
+ raw_body: Option<Bytes>,
49
+ headers: Arc<HashMap<String, String>>,
50
+ cookies: Arc<HashMap<String, String>>,
51
+ validated_params: Option<JsonValue>,
52
+ cache: RefCell<RequestCache>,
53
+ }
54
+
55
+ static KEY_METHOD: LazyId = LazyId::new("method");
56
+ static KEY_PATH: LazyId = LazyId::new("path");
57
+ static KEY_PATH_PARAMS: LazyId = LazyId::new("path_params");
58
+ static KEY_QUERY: LazyId = LazyId::new("query");
59
+ static KEY_RAW_QUERY: LazyId = LazyId::new("raw_query");
60
+ static KEY_HEADERS: LazyId = LazyId::new("headers");
61
+ static KEY_COOKIES: LazyId = LazyId::new("cookies");
62
+ static KEY_BODY: LazyId = LazyId::new("body");
63
+ static KEY_RAW_BODY: LazyId = LazyId::new("raw_body");
64
+ static KEY_PARAMS: LazyId = LazyId::new("params");
65
+
66
+ impl NativeRequest {
67
+ pub(crate) fn from_request_data(request_data: RequestData, validated_params: Option<JsonValue>) -> Self {
68
+ let RequestData {
69
+ path_params,
70
+ query_params,
71
+ raw_query_params,
72
+ body,
73
+ raw_body,
74
+ headers,
75
+ cookies,
76
+ method,
77
+ path,
78
+ ..
79
+ } = request_data;
80
+
81
+ Self {
82
+ method,
83
+ path,
84
+ path_params,
85
+ query_params,
86
+ raw_query_params,
87
+ body,
88
+ raw_body,
89
+ headers,
90
+ cookies,
91
+ validated_params,
92
+ cache: RefCell::new(RequestCache::default()),
93
+ }
94
+ }
95
+
96
+ fn cache_get(cache: &Option<Opaque<Value>>, ruby: &Ruby) -> Option<Value> {
97
+ cache.as_ref().map(|v| v.get_inner_with(ruby))
98
+ }
99
+
100
+ fn cache_set(slot: &mut Option<Opaque<Value>>, value: Value) -> Value {
101
+ *slot = Some(Opaque::from(value));
102
+ value
103
+ }
104
+
105
+ pub(crate) fn method(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
106
+ if let Some(value) = {
107
+ let cache = this.cache.borrow();
108
+ Self::cache_get(&cache.method, ruby)
109
+ } {
110
+ return Ok(value);
111
+ }
112
+ let value = ruby.str_new(&this.method).as_value();
113
+ let mut cache = this.cache.borrow_mut();
114
+ Ok(Self::cache_set(&mut cache.method, value))
115
+ }
116
+
117
+ pub(crate) fn path(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
118
+ if let Some(value) = {
119
+ let cache = this.cache.borrow();
120
+ Self::cache_get(&cache.path, ruby)
121
+ } {
122
+ return Ok(value);
123
+ }
124
+ let value = ruby.str_new(&this.path).as_value();
125
+ let mut cache = this.cache.borrow_mut();
126
+ Ok(Self::cache_set(&mut cache.path, value))
127
+ }
128
+
129
+ pub(crate) fn path_params(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
130
+ if let Some(cached) = {
131
+ let cache = this.cache.borrow();
132
+ Self::cache_get(&cache.path_params, ruby)
133
+ } {
134
+ return Ok(cached);
135
+ }
136
+ let value = map_to_ruby_hash(ruby, this.path_params.as_ref())?;
137
+ let mut cache = this.cache.borrow_mut();
138
+ Ok(Self::cache_set(&mut cache.path_params, value))
139
+ }
140
+
141
+ pub(crate) fn query(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
142
+ if let Some(cached) = {
143
+ let cache = this.cache.borrow();
144
+ Self::cache_get(&cache.query, ruby)
145
+ } {
146
+ return Ok(cached);
147
+ }
148
+ let value = json_to_ruby(ruby, &this.query_params)?;
149
+ let mut cache = this.cache.borrow_mut();
150
+ Ok(Self::cache_set(&mut cache.query, value))
151
+ }
152
+
153
+ pub(crate) fn raw_query(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
154
+ if let Some(cached) = {
155
+ let cache = this.cache.borrow();
156
+ Self::cache_get(&cache.raw_query, ruby)
157
+ } {
158
+ return Ok(cached);
159
+ }
160
+ let value = multimap_to_ruby_hash(ruby, this.raw_query_params.as_ref())?;
161
+ let mut cache = this.cache.borrow_mut();
162
+ Ok(Self::cache_set(&mut cache.raw_query, value))
163
+ }
164
+
165
+ pub(crate) fn headers(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
166
+ if let Some(cached) = {
167
+ let cache = this.cache.borrow();
168
+ Self::cache_get(&cache.headers, ruby)
169
+ } {
170
+ return Ok(cached);
171
+ }
172
+ let value = map_to_ruby_hash(ruby, this.headers.as_ref())?;
173
+ let mut cache = this.cache.borrow_mut();
174
+ Ok(Self::cache_set(&mut cache.headers, value))
175
+ }
176
+
177
+ pub(crate) fn cookies(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
178
+ if let Some(cached) = {
179
+ let cache = this.cache.borrow();
180
+ Self::cache_get(&cache.cookies, ruby)
181
+ } {
182
+ return Ok(cached);
183
+ }
184
+ let value = map_to_ruby_hash(ruby, this.cookies.as_ref())?;
185
+ let mut cache = this.cache.borrow_mut();
186
+ Ok(Self::cache_set(&mut cache.cookies, value))
187
+ }
188
+
189
+ pub(crate) fn body(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
190
+ if let Some(cached) = {
191
+ let cache = this.cache.borrow();
192
+ Self::cache_get(&cache.body, ruby)
193
+ } {
194
+ return Ok(cached);
195
+ }
196
+ let value = json_to_ruby(ruby, &this.body)?;
197
+ let mut cache = this.cache.borrow_mut();
198
+ Ok(Self::cache_set(&mut cache.body, value))
199
+ }
200
+
201
+ pub(crate) fn raw_body(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
202
+ if let Some(cached) = {
203
+ let cache = this.cache.borrow();
204
+ Self::cache_get(&cache.raw_body, ruby)
205
+ } {
206
+ return Ok(cached);
207
+ }
208
+ let value = match &this.raw_body {
209
+ Some(bytes) => ruby.str_from_slice(bytes.as_ref()).as_value(),
210
+ None => ruby.qnil().as_value(),
211
+ };
212
+ let mut cache = this.cache.borrow_mut();
213
+ Ok(Self::cache_set(&mut cache.raw_body, value))
214
+ }
215
+
216
+ pub(crate) fn params(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
217
+ if let Some(value) = {
218
+ let cache = this.cache.borrow();
219
+ Self::cache_get(&cache.params, ruby)
220
+ } {
221
+ return Ok(value);
222
+ }
223
+
224
+ let value = if let Some(validated) = &this.validated_params {
225
+ json_to_ruby(ruby, validated)?
226
+ } else {
227
+ let params = ruby.hash_new();
228
+ if let Some(hash) = RHash::from_value(Self::path_params(ruby, this)?) {
229
+ let _: Value = params.funcall("merge!", (hash,))?;
230
+ }
231
+ if let Some(hash) = RHash::from_value(Self::query(ruby, this)?) {
232
+ let _: Value = params.funcall("merge!", (hash,))?;
233
+ }
234
+ if let Some(hash) = RHash::from_value(Self::headers(ruby, this)?) {
235
+ let _: Value = params.funcall("merge!", (hash,))?;
236
+ }
237
+ if let Some(hash) = RHash::from_value(Self::cookies(ruby, this)?) {
238
+ let _: Value = params.funcall("merge!", (hash,))?;
239
+ }
240
+ params.as_value()
241
+ };
242
+
243
+ let mut cache = this.cache.borrow_mut();
244
+ Ok(Self::cache_set(&mut cache.params, value))
245
+ }
246
+
247
+ pub(crate) fn to_h(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
248
+ if let Some(value) = {
249
+ let cache = this.cache.borrow();
250
+ Self::cache_get(&cache.to_h, ruby)
251
+ } {
252
+ return Ok(value);
253
+ }
254
+
255
+ let hash = ruby.hash_new_capa(10);
256
+ hash.aset(ruby.intern("method"), Self::method(ruby, this)?)?;
257
+ hash.aset(ruby.intern("path"), Self::path(ruby, this)?)?;
258
+ hash.aset(ruby.intern("path_params"), Self::path_params(ruby, this)?)?;
259
+ hash.aset(ruby.intern("query"), Self::query(ruby, this)?)?;
260
+ hash.aset(ruby.intern("raw_query"), Self::raw_query(ruby, this)?)?;
261
+ hash.aset(ruby.intern("headers"), Self::headers(ruby, this)?)?;
262
+ hash.aset(ruby.intern("cookies"), Self::cookies(ruby, this)?)?;
263
+ hash.aset(ruby.intern("body"), Self::body(ruby, this)?)?;
264
+ hash.aset(ruby.intern("raw_body"), Self::raw_body(ruby, this)?)?;
265
+ hash.aset(ruby.intern("params"), Self::params(ruby, this)?)?;
266
+
267
+ let mut cache = this.cache.borrow_mut();
268
+ Ok(Self::cache_set(&mut cache.to_h, hash.as_value()))
269
+ }
270
+
271
+ pub(crate) fn index(ruby: &Ruby, this: &Self, key: Value) -> Result<Value, Error> {
272
+ if let Ok(sym) = Symbol::try_convert(key) {
273
+ return if sym == KEY_METHOD {
274
+ Self::method(ruby, this)
275
+ } else if sym == KEY_PATH {
276
+ Self::path(ruby, this)
277
+ } else if sym == KEY_PATH_PARAMS {
278
+ Self::path_params(ruby, this)
279
+ } else if sym == KEY_QUERY {
280
+ Self::query(ruby, this)
281
+ } else if sym == KEY_RAW_QUERY {
282
+ Self::raw_query(ruby, this)
283
+ } else if sym == KEY_HEADERS {
284
+ Self::headers(ruby, this)
285
+ } else if sym == KEY_COOKIES {
286
+ Self::cookies(ruby, this)
287
+ } else if sym == KEY_BODY {
288
+ Self::body(ruby, this)
289
+ } else if sym == KEY_RAW_BODY {
290
+ Self::raw_body(ruby, this)
291
+ } else if sym == KEY_PARAMS {
292
+ Self::params(ruby, this)
293
+ } else {
294
+ Ok(ruby.qnil().as_value())
295
+ };
296
+ }
297
+
298
+ if let Ok(text) = RString::try_convert(key) {
299
+ let slice = unsafe { text.as_slice() };
300
+ return match slice {
301
+ b"method" => Self::method(ruby, this),
302
+ b"path" => Self::path(ruby, this),
303
+ b"path_params" => Self::path_params(ruby, this),
304
+ b"query" => Self::query(ruby, this),
305
+ b"raw_query" => Self::raw_query(ruby, this),
306
+ b"headers" => Self::headers(ruby, this),
307
+ b"cookies" => Self::cookies(ruby, this),
308
+ b"body" => Self::body(ruby, this),
309
+ b"raw_body" => Self::raw_body(ruby, this),
310
+ b"params" => Self::params(ruby, this),
311
+ _ => Ok(ruby.qnil().as_value()),
312
+ };
313
+ }
314
+
315
+ Ok(ruby.qnil().as_value())
316
+ }
317
+
318
+ #[allow(dead_code)]
319
+ pub(crate) fn mark(&self, marker: &Marker) {
320
+ if let Ok(ruby) = Ruby::get() {
321
+ let cache = self.cache.borrow();
322
+ for handle in [
323
+ &cache.method,
324
+ &cache.path,
325
+ &cache.path_params,
326
+ &cache.query,
327
+ &cache.raw_query,
328
+ &cache.headers,
329
+ &cache.cookies,
330
+ &cache.body,
331
+ &cache.raw_body,
332
+ &cache.params,
333
+ &cache.to_h,
334
+ ]
335
+ .into_iter()
336
+ .filter_map(|value| value.as_ref())
337
+ {
338
+ marker.mark(handle.get_inner_with(&ruby));
339
+ }
340
+ }
341
+ }
342
+ }
@@ -197,14 +197,7 @@ pub fn run_server(
197
197
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
198
198
 
199
199
  ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
200
- let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
201
- Error::new(
202
- ruby.exception_runtime_error(),
203
- format!("Failed to create WebSocket handler: {}", e),
204
- )
205
- })?;
206
-
207
- let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
200
+ let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
208
201
 
209
202
  ws_endpoints.push((path, ws_state));
210
203
 
@@ -204,14 +204,7 @@ pub fn run_server(
204
204
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
205
205
 
206
206
  ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
207
- let handler_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
208
- Error::new(
209
- ruby.exception_runtime_error(),
210
- format!("Failed to create WebSocket handler: {}", e),
211
- )
212
- })?;
213
-
214
- let ws_state = crate::websocket::create_websocket_state(ruby, handler_instance)?;
207
+ let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
215
208
 
216
209
  ws_endpoints.push((path, ws_state));
217
210
 
@@ -153,14 +153,7 @@ impl NativeTestClient {
153
153
 
154
154
  ws_hash.foreach(
155
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)?;
156
+ let ws_state = crate::websocket::create_websocket_state(ruby, factory)?;
164
157
 
165
158
  ws_endpoints.push((path, ws_state));
166
159
 
@@ -360,6 +353,122 @@ impl NativeTestClient {
360
353
  crate::testing::sse::sse_stream_from_response(ruby, &snapshot)
361
354
  }
362
355
 
356
+ /// Send a GraphQL query/mutation
357
+ pub fn graphql(
358
+ ruby: &Ruby,
359
+ this: &Self,
360
+ query: String,
361
+ variables: Value,
362
+ operation_name: Value,
363
+ ) -> Result<Value, Error> {
364
+ let inner_borrow = this.inner.borrow();
365
+ let inner = inner_borrow
366
+ .as_ref()
367
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
368
+
369
+ let json_module = ruby
370
+ .class_object()
371
+ .const_get("JSON")
372
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
373
+
374
+ let variables_json = if variables.is_nil() {
375
+ None
376
+ } else {
377
+ Some(crate::conversion::ruby_value_to_json(ruby, json_module, variables)?)
378
+ };
379
+
380
+ let operation_name_str = if operation_name.is_nil() {
381
+ None
382
+ } else {
383
+ Some(String::try_convert(operation_name)?)
384
+ };
385
+
386
+ let runtime = crate::server::global_runtime(ruby)?;
387
+ let server = inner.http_server.clone();
388
+ let query_value = query.clone();
389
+
390
+ let snapshot = crate::call_without_gvl!(
391
+ block_on_graphql,
392
+ args: (
393
+ runtime, &tokio::runtime::Runtime,
394
+ server, Arc<TestServer>,
395
+ query_value, String,
396
+ variables_json, Option<JsonValue>,
397
+ operation_name_str, Option<String>
398
+ ),
399
+ return_type: Result<ResponseSnapshot, NativeRequestError>
400
+ )
401
+ .map_err(|err| {
402
+ Error::new(
403
+ ruby.exception_runtime_error(),
404
+ format!("GraphQL request failed: {}", err.0),
405
+ )
406
+ })?;
407
+
408
+ response_snapshot_to_ruby(ruby, snapshot)
409
+ }
410
+
411
+ /// Send a GraphQL query and get HTTP status separately
412
+ pub fn graphql_with_status(
413
+ ruby: &Ruby,
414
+ this: &Self,
415
+ query: String,
416
+ variables: Value,
417
+ operation_name: Value,
418
+ ) -> Result<Value, Error> {
419
+ let inner_borrow = this.inner.borrow();
420
+ let inner = inner_borrow
421
+ .as_ref()
422
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
423
+
424
+ let json_module = ruby
425
+ .class_object()
426
+ .const_get("JSON")
427
+ .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
428
+
429
+ let variables_json = if variables.is_nil() {
430
+ None
431
+ } else {
432
+ Some(crate::conversion::ruby_value_to_json(ruby, json_module, variables)?)
433
+ };
434
+
435
+ let operation_name_str = if operation_name.is_nil() {
436
+ None
437
+ } else {
438
+ Some(String::try_convert(operation_name)?)
439
+ };
440
+
441
+ let runtime = crate::server::global_runtime(ruby)?;
442
+ let server = inner.http_server.clone();
443
+ let query_value = query.clone();
444
+
445
+ let snapshot = crate::call_without_gvl!(
446
+ block_on_graphql,
447
+ args: (
448
+ runtime, &tokio::runtime::Runtime,
449
+ server, Arc<TestServer>,
450
+ query_value, String,
451
+ variables_json, Option<JsonValue>,
452
+ operation_name_str, Option<String>
453
+ ),
454
+ return_type: Result<ResponseSnapshot, NativeRequestError>
455
+ )
456
+ .map_err(|err| {
457
+ Error::new(
458
+ ruby.exception_runtime_error(),
459
+ format!("GraphQL request failed: {}", err.0),
460
+ )
461
+ })?;
462
+
463
+ let status = snapshot.status;
464
+ let response = response_snapshot_to_ruby(ruby, snapshot)?;
465
+
466
+ let array = ruby.ary_new_capa(2);
467
+ array.push(ruby.integer_from_i64(status as i64))?;
468
+ array.push(response)?;
469
+ Ok(array.as_value())
470
+ }
471
+
363
472
  /// GC mark hook so Ruby keeps handler closures alive.
364
473
  #[allow(dead_code)]
365
474
  pub fn mark(&self, marker: &Marker) {
@@ -381,7 +490,7 @@ fn websocket_timeout() -> Duration {
381
490
  Duration::from_millis(timeout_ms)
382
491
  }
383
492
 
384
- fn block_on_request(
493
+ pub fn block_on_request(
385
494
  runtime: &tokio::runtime::Runtime,
386
495
  server: Arc<TestServer>,
387
496
  method: Method,
@@ -536,3 +645,53 @@ pub async fn execute_request(
536
645
  fn snapshot_err_to_native(err: SnapshotError) -> NativeRequestError {
537
646
  NativeRequestError(err.to_string())
538
647
  }
648
+
649
+ pub fn block_on_graphql(
650
+ runtime: &tokio::runtime::Runtime,
651
+ server: Arc<TestServer>,
652
+ query: String,
653
+ variables: Option<JsonValue>,
654
+ operation_name: Option<String>,
655
+ ) -> Result<ResponseSnapshot, NativeRequestError> {
656
+ runtime.block_on(execute_graphql_request(server, query, variables, operation_name))
657
+ }
658
+
659
+ async fn execute_graphql_request(
660
+ server: Arc<TestServer>,
661
+ query: String,
662
+ variables: Option<JsonValue>,
663
+ operation_name: Option<String>,
664
+ ) -> Result<ResponseSnapshot, NativeRequestError> {
665
+ let mut body = serde_json::json!({ "query": query });
666
+ if let Some(vars) = variables {
667
+ body["variables"] = vars;
668
+ }
669
+ if let Some(op_name) = operation_name {
670
+ body["operationName"] = JsonValue::String(op_name);
671
+ }
672
+
673
+ let response = server.post("/graphql").json(&body).await;
674
+ let snapshot = snapshot_response(response).await.map_err(snapshot_err_to_native)?;
675
+ Ok(snapshot)
676
+ }
677
+
678
+ fn response_snapshot_to_ruby(ruby: &Ruby, snapshot: ResponseSnapshot) -> Result<Value, Error> {
679
+ let hash = ruby.hash_new();
680
+
681
+ hash.aset(
682
+ ruby.intern("status_code"),
683
+ ruby.integer_from_i64(snapshot.status as i64),
684
+ )?;
685
+
686
+ let headers_hash = ruby.hash_new();
687
+ for (key, value) in snapshot.headers {
688
+ headers_hash.aset(ruby.str_new(&key), ruby.str_new(&value))?;
689
+ }
690
+ hash.aset(ruby.intern("headers"), headers_hash)?;
691
+
692
+ let body_value = ruby.str_new(&String::from_utf8_lossy(&snapshot.body));
693
+ hash.aset(ruby.intern("body"), body_value)?;
694
+ hash.aset(ruby.intern("body_text"), body_value)?;
695
+
696
+ Ok(hash.as_value())
697
+ }