spikard 0.8.3 → 0.10.2

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -10
  3. data/ext/spikard_rb/Cargo.lock +234 -162
  4. data/ext/spikard_rb/Cargo.toml +2 -2
  5. data/ext/spikard_rb/extconf.rb +4 -3
  6. data/lib/spikard/config.rb +88 -12
  7. data/lib/spikard/testing.rb +3 -1
  8. data/lib/spikard/version.rb +1 -1
  9. data/lib/spikard.rb +11 -0
  10. data/vendor/crates/spikard-bindings-shared/Cargo.toml +3 -6
  11. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +8 -8
  12. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +2 -2
  13. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +4 -4
  14. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +10 -4
  15. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +3 -3
  16. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +10 -5
  17. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +829 -0
  18. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +587 -0
  19. data/vendor/crates/spikard-bindings-shared/src/lib.rs +7 -0
  20. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +11 -11
  21. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +9 -37
  22. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +436 -3
  23. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +944 -0
  24. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +4 -4
  25. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +3 -2
  26. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +13 -13
  27. data/vendor/crates/spikard-bindings-shared/tests/{comprehensive_coverage.rs → full_coverage.rs} +10 -5
  28. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +14 -14
  29. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +669 -0
  30. data/vendor/crates/spikard-core/Cargo.toml +3 -3
  31. data/vendor/crates/spikard-core/src/di/container.rs +1 -1
  32. data/vendor/crates/spikard-core/src/di/factory.rs +2 -2
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +2 -2
  34. data/vendor/crates/spikard-core/src/di/value.rs +1 -1
  35. data/vendor/crates/spikard-core/src/http.rs +75 -0
  36. data/vendor/crates/spikard-core/src/lifecycle.rs +43 -43
  37. data/vendor/crates/spikard-core/src/parameters.rs +14 -19
  38. data/vendor/crates/spikard-core/src/problem.rs +1 -1
  39. data/vendor/crates/spikard-core/src/request_data.rs +7 -16
  40. data/vendor/crates/spikard-core/src/router.rs +6 -0
  41. data/vendor/crates/spikard-core/src/schema_registry.rs +2 -3
  42. data/vendor/crates/spikard-core/src/type_hints.rs +3 -2
  43. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +1 -1
  44. data/vendor/crates/spikard-core/src/validation/mod.rs +1 -1
  45. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +1 -1
  46. data/vendor/crates/spikard-core/tests/error_mapper.rs +2 -2
  47. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +1 -1
  48. data/vendor/crates/spikard-core/tests/parameters_full.rs +1 -1
  49. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +1 -1
  50. data/vendor/crates/spikard-core/tests/validation_coverage.rs +4 -4
  51. data/vendor/crates/spikard-http/Cargo.toml +4 -2
  52. data/vendor/crates/spikard-http/src/cors.rs +32 -11
  53. data/vendor/crates/spikard-http/src/di_handler.rs +12 -8
  54. data/vendor/crates/spikard-http/src/grpc/framing.rs +469 -0
  55. data/vendor/crates/spikard-http/src/grpc/handler.rs +887 -25
  56. data/vendor/crates/spikard-http/src/grpc/mod.rs +114 -22
  57. data/vendor/crates/spikard-http/src/grpc/service.rs +232 -2
  58. data/vendor/crates/spikard-http/src/grpc/streaming.rs +80 -2
  59. data/vendor/crates/spikard-http/src/handler_trait.rs +204 -27
  60. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +15 -15
  61. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +2 -2
  62. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2 -2
  63. data/vendor/crates/spikard-http/src/lib.rs +1 -1
  64. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +2 -2
  65. data/vendor/crates/spikard-http/src/lifecycle.rs +4 -4
  66. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +2 -0
  67. data/vendor/crates/spikard-http/src/server/fast_router.rs +186 -0
  68. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +324 -23
  69. data/vendor/crates/spikard-http/src/server/handler.rs +33 -22
  70. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +21 -2
  71. data/vendor/crates/spikard-http/src/server/mod.rs +125 -20
  72. data/vendor/crates/spikard-http/src/server/request_extraction.rs +126 -44
  73. data/vendor/crates/spikard-http/src/server/routing_factory.rs +80 -69
  74. data/vendor/crates/spikard-http/tests/common/handlers.rs +2 -2
  75. data/vendor/crates/spikard-http/tests/common/test_builders.rs +12 -12
  76. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +2 -2
  77. data/vendor/crates/spikard-http/tests/di_integration.rs +6 -6
  78. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +430 -0
  79. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +738 -0
  80. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +13 -9
  81. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +974 -0
  82. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +2 -2
  83. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +4 -4
  84. data/vendor/crates/spikard-http/tests/server_config_builder.rs +2 -2
  85. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -0
  86. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +140 -0
  87. data/vendor/crates/spikard-rb/Cargo.toml +3 -1
  88. data/vendor/crates/spikard-rb/src/conversion.rs +138 -4
  89. data/vendor/crates/spikard-rb/src/grpc/handler.rs +706 -229
  90. data/vendor/crates/spikard-rb/src/grpc/mod.rs +6 -2
  91. data/vendor/crates/spikard-rb/src/gvl.rs +2 -2
  92. data/vendor/crates/spikard-rb/src/handler.rs +169 -91
  93. data/vendor/crates/spikard-rb/src/lib.rs +444 -62
  94. data/vendor/crates/spikard-rb/src/lifecycle.rs +29 -1
  95. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +108 -43
  96. data/vendor/crates/spikard-rb/src/request.rs +117 -20
  97. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +52 -25
  98. data/vendor/crates/spikard-rb/src/server.rs +23 -14
  99. data/vendor/crates/spikard-rb/src/testing/client.rs +5 -4
  100. data/vendor/crates/spikard-rb/src/testing/sse.rs +1 -36
  101. data/vendor/crates/spikard-rb/src/testing/websocket.rs +3 -38
  102. data/vendor/crates/spikard-rb/src/websocket.rs +32 -23
  103. data/vendor/crates/spikard-rb-macros/Cargo.toml +1 -1
  104. metadata +14 -4
  105. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +0 -5
  106. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +0 -2
@@ -0,0 +1,944 @@
1
+ //! Response interpretation and handling for language bindings
2
+ //!
3
+ //! This module provides traits and types for abstracting over language-specific
4
+ //! response patterns, enabling consistent handling across all bindings
5
+ //! (Python, Node.js, Ruby, PHP, WASM).
6
+ //!
7
+ //! # Overview
8
+ //!
9
+ //! Language bindings receive responses from handler functions that can take three forms:
10
+ //! 1. **Streaming responses** - Iterables that yield chunks incrementally
11
+ //! 2. **Custom responses** - Objects with explicit status codes, headers, and bodies
12
+ //! 3. **Plain responses** - Simple JSON values that become 200 OK responses
13
+ //!
14
+ //! Rather than duplicating response detection logic in each binding, this module
15
+ //! provides a shared [`ResponseInterpreter`] trait that bindings implement to translate
16
+ //! language-specific values into a unified [`InterpretedResponse`] enum.
17
+ //!
18
+ //! # Benefits
19
+ //!
20
+ //! - **Code reuse**: ~150 LOC of response detection logic shared across all bindings
21
+ //! - **Consistency**: All bindings interpret responses identically
22
+ //! - **Maintainability**: Single source of truth for response patterns
23
+ //! - **Extensibility**: Adding new response types requires changes in one place
24
+ //!
25
+ //! # Architecture
26
+ //!
27
+ //! ```text
28
+ //! Handler Function (Language-specific)
29
+ //! ↓
30
+ //! Language Value (Python object, Node object, etc.)
31
+ //! ↓
32
+ //! [ResponseInterpreter impl for language] — interprets language value
33
+ //! ↓
34
+ //! InterpretedResponse — unified enum (streaming, custom, or plain)
35
+ //! ↓
36
+ //! HTTP Response (status, headers, body)
37
+ //! ```
38
+ //!
39
+ //! # Examples
40
+ //!
41
+ //! ## Implementing `ResponseInterpreter` for a Language Binding
42
+ //!
43
+ //! ```ignore
44
+ //! use spikard_bindings_shared::{ResponseInterpreter, InterpretedResponse, StreamSource};
45
+ //! use serde_json::Value;
46
+ //! use std::collections::HashMap;
47
+ //!
48
+ //! struct PythonInterpreter;
49
+ //!
50
+ //! impl ResponseInterpreter for PythonInterpreter {
51
+ //! type LanguageValue = PyObject;
52
+ //! type Error = PyErr;
53
+ //!
54
+ //! fn is_streaming(&self, value: &Self::LanguageValue) -> bool {
55
+ //! // Check if object is iterable/generator
56
+ //! // e.g., hasattr(obj, '__iter__') and not isinstance(obj, dict)
57
+ //! todo!()
58
+ //! }
59
+ //!
60
+ //! fn is_custom_response(&self, value: &Self::LanguageValue) -> bool {
61
+ //! // Check if object has 'status_code' or 'headers' attributes
62
+ //! todo!()
63
+ //! }
64
+ //!
65
+ //! fn interpret(&self, value: &Self::LanguageValue) -> Result<InterpretedResponse, Self::Error> {
66
+ //! if self.is_streaming(value) {
67
+ //! // Wrap Python iterator in StreamSource
68
+ //! let stream = PythonStreamSource { obj: value.clone() };
69
+ //! Ok(InterpretedResponse::Streaming {
70
+ //! enumerator: Box::new(stream),
71
+ //! status: 200,
72
+ //! headers: HashMap::new(),
73
+ //! })
74
+ //! } else if self.is_custom_response(value) {
75
+ //! // Extract status, headers, body from custom response object
76
+ //! let status = extract_status(value)?;
77
+ //! let headers = extract_headers(value)?;
78
+ //! let body = extract_body(value)?;
79
+ //! Ok(InterpretedResponse::Custom {
80
+ //! status,
81
+ //! headers,
82
+ //! body,
83
+ //! raw_body: None,
84
+ //! })
85
+ //! } else {
86
+ //! // Treat as plain JSON
87
+ //! let body = python_to_json(value)?;
88
+ //! Ok(InterpretedResponse::Plain { body })
89
+ //! }
90
+ //! }
91
+ //! }
92
+ //! ```
93
+
94
+ use serde_json::Value;
95
+ use std::collections::HashMap;
96
+
97
+ /// A trait for language-specific response stream sources
98
+ ///
99
+ /// This trait abstracts over different language iteration mechanisms
100
+ /// (Python generators, Node.js async iterables, Ruby enumerables, etc.)
101
+ /// to provide a uniform interface for streaming responses.
102
+ ///
103
+ /// # Design Goals
104
+ ///
105
+ /// - **Object-safe**: No generic type parameters, supports dynamic dispatch via `Box<dyn StreamSource>`
106
+ /// - **Chunk-oriented**: Returns `Option<Vec<u8>>` for each iteration, `None` on completion
107
+ /// - **Memory-efficient**: Yields data incrementally without buffering entire response
108
+ /// - **Async-compatible**: `Send + Sync` allows usage with async runtimes
109
+ ///
110
+ /// # Implementation Notes
111
+ ///
112
+ /// - Must be stateful: tracks position in the underlying iterator/generator
113
+ /// - Should handle language-specific iteration protocol (e.g., Python's `__next__`)
114
+ /// - Must propagate errors from the underlying iterator
115
+ /// - Should handle encoding (binary data or UTF-8 conversion as needed)
116
+ ///
117
+ /// # Examples
118
+ ///
119
+ /// ```ignore
120
+ /// // Python generator implementation
121
+ /// struct PythonStreamSource {
122
+ /// generator: PyObject,
123
+ /// py: Python<'static>,
124
+ /// }
125
+ ///
126
+ /// impl StreamSource for PythonStreamSource {
127
+ /// fn next_chunk(&mut self) -> Option<Vec<u8>> {
128
+ /// // Call next() on Python generator
129
+ /// // Convert chunk to Vec<u8>
130
+ /// // Return None when StopIteration raised
131
+ /// todo!()
132
+ /// }
133
+ /// }
134
+ /// ```
135
+ pub trait StreamSource: Send + Sync {
136
+ /// Get the next chunk of data from the stream
137
+ ///
138
+ /// # Returns
139
+ ///
140
+ /// - `Some(chunk)` - A chunk of data (may be empty `vec![]` for flush signals)
141
+ /// - `None` - End of stream reached
142
+ ///
143
+ /// # Errors
144
+ ///
145
+ /// Errors from the underlying iterator should be handled by converting
146
+ /// to UTF-8 encoded error messages in the chunk stream, or by returning
147
+ /// `None` to signal end of stream and allowing the binding to handle errors
148
+ /// before streaming begins.
149
+ fn next_chunk(&mut self) -> Option<Vec<u8>>;
150
+ }
151
+
152
+ /// Represents an interpreted HTTP response from a handler function
153
+ ///
154
+ /// This enum captures the three possible response patterns:
155
+ /// 1. **Streaming** - Iterative data delivery
156
+ /// 2. **Custom** - Explicit control over status, headers, and body
157
+ /// 3. **Plain** - Simple JSON response
158
+ ///
159
+ /// # Semantics
160
+ ///
161
+ /// - **Streaming**: Used for server-sent events, file downloads, large responses
162
+ /// - Status code and headers are sent immediately
163
+ /// - Chunks are sent as they become available
164
+ /// - HTTP connection remains open until `StreamSource::next_chunk()` returns `None`
165
+ ///
166
+ /// - **Custom**: Used for responses with explicit status codes or headers
167
+ /// - `status` is required (default 200 if not specified)
168
+ /// - `headers` can be empty for default behavior
169
+ /// - `body` is the JSON response body (may be `None` for streaming custom responses)
170
+ /// - `raw_body` can contain pre-encoded bytes (skips JSON serialization)
171
+ ///
172
+ /// - **Plain**: Simplest form, always 200 OK
173
+ /// - `body` is a `Value` that will be JSON-serialized
174
+ /// - No custom headers or status codes
175
+ /// - Most common response type (~90% of real-world APIs)
176
+ ///
177
+ /// # Performance Characteristics
178
+ ///
179
+ /// - **Streaming**: Zero-copy streaming, minimal memory overhead
180
+ /// - **Custom**: One allocation for headers map
181
+ /// - **Plain**: One allocation for `Value`
182
+ ///
183
+ /// # Examples
184
+ ///
185
+ /// ```ignore
186
+ /// use spikard_bindings_shared::InterpretedResponse;
187
+ /// use serde_json::json;
188
+ /// use std::collections::HashMap;
189
+ ///
190
+ /// // Streaming response
191
+ /// let response = InterpretedResponse::Streaming {
192
+ /// enumerator: Box::new(my_stream),
193
+ /// status: 200,
194
+ /// headers: Default::default(),
195
+ /// };
196
+ ///
197
+ /// // Custom response with status and headers
198
+ /// let mut headers = HashMap::new();
199
+ /// headers.insert("content-type".to_string(), "application/json".to_string());
200
+ /// let response = InterpretedResponse::Custom {
201
+ /// status: 201,
202
+ /// headers,
203
+ /// body: Some(json!({ "id": 42 })),
204
+ /// raw_body: None,
205
+ /// };
206
+ ///
207
+ /// // Plain JSON response
208
+ /// let response = InterpretedResponse::Plain {
209
+ /// body: json!({ "success": true }),
210
+ /// };
211
+ /// ```
212
+ pub enum InterpretedResponse {
213
+ /// Streaming response with incremental data delivery
214
+ ///
215
+ /// Used for responses where data is produced over time:
216
+ /// - File downloads
217
+ /// - Server-sent events
218
+ /// - Large data sets
219
+ /// - Real-time data feeds
220
+ ///
221
+ /// # Fields
222
+ ///
223
+ /// - `enumerator`: Boxed `StreamSource` trait object that yields chunks
224
+ /// - `status`: HTTP status code (typically 200)
225
+ /// - `headers`: Response headers (e.g., Content-Type, Cache-Control)
226
+ Streaming {
227
+ /// Stream of data chunks
228
+ enumerator: Box<dyn StreamSource>,
229
+ /// HTTP status code
230
+ status: u16,
231
+ /// Response headers
232
+ headers: HashMap<String, String>,
233
+ },
234
+
235
+ /// Custom response with explicit control over HTTP semantics
236
+ ///
237
+ /// Used when the handler wants fine-grained control:
238
+ /// - Custom status codes (201, 202, 204, 3xx, 4xx, 5xx)
239
+ /// - Custom response headers
240
+ /// - Pre-encoded binary body
241
+ /// - Streaming with custom status/headers
242
+ ///
243
+ /// # Fields
244
+ ///
245
+ /// - `status`: HTTP status code
246
+ /// - `headers`: Custom headers
247
+ /// - `body`: JSON body (None for 204 No Content or streaming responses)
248
+ /// - `raw_body`: Pre-encoded bytes (takes precedence over `body` if present)
249
+ Custom {
250
+ /// HTTP status code
251
+ status: u16,
252
+ /// Response headers
253
+ headers: HashMap<String, String>,
254
+ /// JSON response body (may be None)
255
+ body: Option<Value>,
256
+ /// Pre-encoded response body (takes precedence over body)
257
+ raw_body: Option<Vec<u8>>,
258
+ },
259
+
260
+ /// Plain JSON response with default HTTP semantics
261
+ ///
262
+ /// Simplest and most common response type. Automatically:
263
+ /// - Sets status to 200 OK
264
+ /// - Sets Content-Type: application/json
265
+ /// - Serializes body as JSON
266
+ ///
267
+ /// # Fields
268
+ ///
269
+ /// - `body`: The JSON response body
270
+ Plain { body: Value },
271
+ }
272
+
273
+ impl std::fmt::Debug for InterpretedResponse {
274
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275
+ match self {
276
+ Self::Streaming { status, headers, .. } => f
277
+ .debug_struct("Streaming")
278
+ .field("status", status)
279
+ .field("headers", headers)
280
+ .field("enumerator", &"<StreamSource>")
281
+ .finish(),
282
+ Self::Custom {
283
+ status,
284
+ headers,
285
+ body,
286
+ raw_body,
287
+ } => f
288
+ .debug_struct("Custom")
289
+ .field("status", status)
290
+ .field("headers", headers)
291
+ .field("body", body)
292
+ .field("raw_body", &raw_body.as_ref().map(|_| "<Vec<u8>>"))
293
+ .finish(),
294
+ Self::Plain { body } => f.debug_struct("Plain").field("body", body).finish(),
295
+ }
296
+ }
297
+ }
298
+
299
+ impl InterpretedResponse {
300
+ /// Get the HTTP status code for this response
301
+ ///
302
+ /// # Returns
303
+ ///
304
+ /// The status code (200 for Plain, otherwise as specified)
305
+ #[must_use]
306
+ pub const fn status(&self) -> u16 {
307
+ match self {
308
+ Self::Streaming { status, .. } | Self::Custom { status, .. } => *status,
309
+ Self::Plain { .. } => 200,
310
+ }
311
+ }
312
+
313
+ /// Get the response headers, if any
314
+ ///
315
+ /// # Returns
316
+ ///
317
+ /// - `Some(headers)` for Streaming or Custom responses with headers
318
+ /// - `None` for responses without custom headers
319
+ #[must_use]
320
+ #[allow(clippy::missing_const_for_fn)]
321
+ pub fn headers(&self) -> Option<&HashMap<String, String>> {
322
+ match self {
323
+ Self::Streaming { headers, .. } | Self::Custom { headers, .. } => Some(headers),
324
+ Self::Plain { .. } => None,
325
+ }
326
+ }
327
+
328
+ /// Check if this is a streaming response
329
+ #[must_use]
330
+ pub const fn is_streaming(&self) -> bool {
331
+ matches!(self, Self::Streaming { .. })
332
+ }
333
+
334
+ /// Check if this is a custom response
335
+ #[must_use]
336
+ pub const fn is_custom(&self) -> bool {
337
+ matches!(self, Self::Custom { .. })
338
+ }
339
+
340
+ /// Check if this is a plain response
341
+ #[must_use]
342
+ pub const fn is_plain(&self) -> bool {
343
+ matches!(self, Self::Plain { .. })
344
+ }
345
+ }
346
+
347
+ /// A trait for interpreting language-specific response values
348
+ ///
349
+ /// Language bindings implement this trait to translate handler response values
350
+ /// into a unified [`InterpretedResponse`] enum. This consolidates response detection
351
+ /// logic that was previously duplicated across bindings.
352
+ ///
353
+ /// # Design
354
+ ///
355
+ /// Each binding has its own response patterns:
356
+ /// - **Python**: Functions return values, generators for streaming, Response objects for custom
357
+ /// - **Node.js**: Functions return values or Promises, `AsyncIterable`s for streaming, Response objects
358
+ /// - **Ruby**: Methods return values, Enumerables for streaming, Response objects
359
+ /// - **PHP**: Functions return values, Generators for streaming, Response objects
360
+ /// - **WASM**: Functions return values or Promises (no streaming in initial implementation)
361
+ ///
362
+ /// This trait allows each binding to detect these patterns independently while
363
+ /// sharing the response handling downstream.
364
+ ///
365
+ /// # Associated Types
366
+ ///
367
+ /// - `LanguageValue`: The language's value type (`PyObject`, `JsValue`, `VALUE`, etc.)
368
+ /// - `Error`: The language's error type (`PyErr`, `napi::Error`, `magnus::Error`, etc.)
369
+ ///
370
+ /// # Methods
371
+ ///
372
+ /// Bindings should implement detection logic in `is_streaming` and `is_custom_response`,
373
+ /// then use those in `interpret` to construct the appropriate [`InterpretedResponse`] variant.
374
+ ///
375
+ /// # Example Implementation
376
+ ///
377
+ /// ```ignore
378
+ /// struct PythonInterpreter;
379
+ ///
380
+ /// impl ResponseInterpreter for PythonInterpreter {
381
+ /// type LanguageValue = PyObject;
382
+ /// type Error = PyErr;
383
+ ///
384
+ /// fn is_streaming(&self, value: &PyObject) -> bool {
385
+ /// // Check __iter__ and __next__ methods
386
+ /// Python::with_gil(|py| {
387
+ /// value.getattr(py, "__iter__").is_ok() &&
388
+ /// value.getattr(py, "__next__").is_ok() &&
389
+ /// !is_dict(value)
390
+ /// })
391
+ /// }
392
+ ///
393
+ /// fn is_custom_response(&self, value: &PyObject) -> bool {
394
+ /// // Check for status_code or headers attributes
395
+ /// Python::with_gil(|py| {
396
+ /// value.getattr(py, "status_code").is_ok() ||
397
+ /// value.getattr(py, "headers").is_ok()
398
+ /// })
399
+ /// }
400
+ ///
401
+ /// fn interpret(&self, value: &PyObject) -> Result<InterpretedResponse, PyErr> {
402
+ /// if self.is_streaming(value) {
403
+ /// // Create StreamSource wrapper
404
+ /// } else if self.is_custom_response(value) {
405
+ /// // Extract status, headers, body
406
+ /// } else {
407
+ /// // Treat as plain JSON
408
+ /// }
409
+ /// }
410
+ /// }
411
+ /// ```
412
+ pub trait ResponseInterpreter {
413
+ /// The language-specific response value type
414
+ ///
415
+ /// Examples: `PyObject` (Python), `JsValue` (Node.js), `VALUE` (Ruby), etc.
416
+ type LanguageValue;
417
+
418
+ /// The language-specific error type
419
+ ///
420
+ /// Examples: `PyErr` (Python), `napi::Error` (Node.js), `magnus::Error` (Ruby), etc.
421
+ type Error: std::fmt::Display;
422
+
423
+ /// Check if a value is a streaming response
424
+ ///
425
+ /// Streaming responses are iterables that yield chunks incrementally.
426
+ /// This method should detect the language-specific pattern for iteration
427
+ /// without consuming the value.
428
+ ///
429
+ /// # Implementation Notes
430
+ ///
431
+ /// - Should not consume the value (non-mutable, non-destructive check)
432
+ /// - Should return false for dicts/objects (they're custom responses or plain values)
433
+ /// - Should return true for iterators, generators, async iterables, etc.
434
+ ///
435
+ /// # Examples
436
+ ///
437
+ /// - Python: `hasattr(obj, '__iter__') and hasattr(obj, '__next__') and not isinstance(obj, dict)`
438
+ /// - Node.js: `obj && typeof obj[Symbol.asyncIterator] === 'function'`
439
+ /// - Ruby: `obj.respond_to?(:each) and !obj.is_a?(Hash)`
440
+ ///
441
+ /// # Returns
442
+ ///
443
+ /// `true` if the value is a streaming response, `false` otherwise
444
+ fn is_streaming(&self, value: &Self::LanguageValue) -> bool;
445
+
446
+ /// Check if a value is a custom response object
447
+ ///
448
+ /// Custom responses have explicit status codes, headers, or body control.
449
+ /// This method should detect the language-specific pattern for response objects
450
+ /// without consuming the value.
451
+ ///
452
+ /// # Implementation Notes
453
+ ///
454
+ /// - Should not consume the value (non-mutable, non-destructive check)
455
+ /// - Should return false for plain JSON types (primitives, arrays, plain dicts)
456
+ /// - Should return true for response wrapper objects
457
+ ///
458
+ /// # Examples
459
+ ///
460
+ /// - Python: `hasattr(obj, 'status_code') or hasattr(obj, 'headers')`
461
+ /// - Node.js: `obj && (obj.statusCode !== undefined or obj.status !== undefined)`
462
+ /// - Ruby: `obj.respond_to?(:status_code) or obj.respond_to?(:headers)`
463
+ ///
464
+ /// # Returns
465
+ ///
466
+ /// `true` if the value is a custom response, `false` otherwise
467
+ fn is_custom_response(&self, value: &Self::LanguageValue) -> bool;
468
+
469
+ /// Interpret a language-specific value into a unified response format
470
+ ///
471
+ /// This method performs the full interpretation logic:
472
+ /// 1. Checks if streaming (call `is_streaming()`)
473
+ /// 2. Checks if custom response (call `is_custom_response()`)
474
+ /// 3. Otherwise treats as plain JSON
475
+ ///
476
+ /// Each branch extracts the relevant data and constructs an [`InterpretedResponse`].
477
+ ///
478
+ /// # Arguments
479
+ ///
480
+ /// * `value` - The language-specific value from the handler
481
+ ///
482
+ /// # Returns
483
+ ///
484
+ /// - `Ok(InterpretedResponse)` - Successfully interpreted response
485
+ /// - `Err(Self::Error)` - Error during interpretation (type conversion, missing fields, etc.)
486
+ ///
487
+ /// # Errors
488
+ ///
489
+ /// Returns `Err(Self::Error)` if:
490
+ /// - Streaming source creation failed
491
+ /// - Status code extraction failed
492
+ /// - Header extraction/conversion failed
493
+ /// - Body conversion to JSON failed
494
+ fn interpret(&self, value: &Self::LanguageValue) -> Result<InterpretedResponse, Self::Error>;
495
+ }
496
+
497
+ #[cfg(test)]
498
+ mod tests {
499
+ use super::*;
500
+
501
+ /// Mock `StreamSource` for testing
502
+ struct MockStreamSource {
503
+ chunks: Vec<Vec<u8>>,
504
+ index: usize,
505
+ }
506
+
507
+ impl MockStreamSource {
508
+ fn new(chunks: Vec<Vec<u8>>) -> Self {
509
+ Self { chunks, index: 0 }
510
+ }
511
+ }
512
+
513
+ impl StreamSource for MockStreamSource {
514
+ fn next_chunk(&mut self) -> Option<Vec<u8>> {
515
+ if self.index < self.chunks.len() {
516
+ let chunk = self.chunks[self.index].clone();
517
+ self.index += 1;
518
+ Some(chunk)
519
+ } else {
520
+ None
521
+ }
522
+ }
523
+ }
524
+
525
+ /// Mock interpreter for testing
526
+ enum TestValue {
527
+ Stream,
528
+ Custom,
529
+ Plain,
530
+ }
531
+
532
+ #[derive(Debug)]
533
+ struct TestError(String);
534
+
535
+ impl std::fmt::Display for TestError {
536
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537
+ write!(f, "{}", self.0)
538
+ }
539
+ }
540
+
541
+ struct TestInterpreter;
542
+
543
+ impl ResponseInterpreter for TestInterpreter {
544
+ type LanguageValue = TestValue;
545
+ type Error = TestError;
546
+
547
+ fn is_streaming(&self, value: &Self::LanguageValue) -> bool {
548
+ matches!(value, TestValue::Stream)
549
+ }
550
+
551
+ fn is_custom_response(&self, value: &Self::LanguageValue) -> bool {
552
+ matches!(value, TestValue::Custom)
553
+ }
554
+
555
+ fn interpret(&self, value: &Self::LanguageValue) -> Result<InterpretedResponse, Self::Error> {
556
+ match value {
557
+ TestValue::Stream => Ok(InterpretedResponse::Streaming {
558
+ enumerator: Box::new(MockStreamSource::new(vec![b"chunk1".to_vec(), b"chunk2".to_vec()])),
559
+ status: 200,
560
+ headers: HashMap::new(),
561
+ }),
562
+ TestValue::Custom => {
563
+ let mut headers = HashMap::new();
564
+ headers.insert("x-custom".to_string(), "header".to_string());
565
+ Ok(InterpretedResponse::Custom {
566
+ status: 201,
567
+ headers,
568
+ body: Some(Value::from("custom body")),
569
+ raw_body: None,
570
+ })
571
+ }
572
+ TestValue::Plain => Ok(InterpretedResponse::Plain {
573
+ body: Value::from("plain body"),
574
+ }),
575
+ }
576
+ }
577
+ }
578
+
579
+ #[test]
580
+ fn test_stream_source_trait_object_safety() {
581
+ let mut stream = MockStreamSource::new(vec![b"test".to_vec()]);
582
+ assert_eq!(stream.next_chunk(), Some(b"test".to_vec()));
583
+ assert_eq!(stream.next_chunk(), None);
584
+
585
+ // Verify we can box it
586
+ let mut boxed: Box<dyn StreamSource> = Box::new(MockStreamSource::new(vec![b"test".to_vec()]));
587
+ assert!(boxed.next_chunk().is_some());
588
+ }
589
+
590
+ #[test]
591
+ fn test_interpreted_response_streaming_construction() {
592
+ let stream = MockStreamSource::new(vec![b"chunk".to_vec()]);
593
+ let mut headers = HashMap::new();
594
+ headers.insert("content-type".to_string(), "application/octet-stream".to_string());
595
+
596
+ let response = InterpretedResponse::Streaming {
597
+ enumerator: Box::new(stream),
598
+ status: 200,
599
+ headers,
600
+ };
601
+
602
+ assert!(response.is_streaming());
603
+ assert!(!response.is_custom());
604
+ assert!(!response.is_plain());
605
+ assert_eq!(response.status(), 200);
606
+ assert!(response.headers().is_some());
607
+ }
608
+
609
+ #[test]
610
+ fn test_interpreted_response_custom_construction() {
611
+ let mut headers = HashMap::new();
612
+ headers.insert("x-custom-header".to_string(), "value".to_string());
613
+
614
+ let response = InterpretedResponse::Custom {
615
+ status: 201,
616
+ headers,
617
+ body: Some(serde_json::json!({ "id": 42 })),
618
+ raw_body: None,
619
+ };
620
+
621
+ assert!(!response.is_streaming());
622
+ assert!(response.is_custom());
623
+ assert!(!response.is_plain());
624
+ assert_eq!(response.status(), 201);
625
+ assert!(response.headers().is_some());
626
+ assert_eq!(response.headers().unwrap().len(), 1);
627
+ }
628
+
629
+ #[test]
630
+ fn test_interpreted_response_plain_construction() {
631
+ let response = InterpretedResponse::Plain {
632
+ body: serde_json::json!({ "message": "hello" }),
633
+ };
634
+
635
+ assert!(!response.is_streaming());
636
+ assert!(!response.is_custom());
637
+ assert!(response.is_plain());
638
+ assert_eq!(response.status(), 200);
639
+ assert_eq!(response.headers(), None);
640
+ }
641
+
642
+ #[test]
643
+ fn test_interpreted_response_status_codes() {
644
+ let codes = vec![200u16, 201, 202, 204, 400, 401, 403, 404, 500, 502, 503];
645
+
646
+ for code in codes {
647
+ let response = InterpretedResponse::Custom {
648
+ status: code,
649
+ headers: HashMap::new(),
650
+ body: None,
651
+ raw_body: None,
652
+ };
653
+ assert_eq!(response.status(), code);
654
+ }
655
+ }
656
+
657
+ #[test]
658
+ fn test_interpreted_response_headers_empty() {
659
+ let response = InterpretedResponse::Custom {
660
+ status: 200,
661
+ headers: HashMap::new(),
662
+ body: None,
663
+ raw_body: None,
664
+ };
665
+
666
+ let headers = response.headers().unwrap();
667
+ assert_eq!(headers.len(), 0);
668
+ }
669
+
670
+ #[test]
671
+ fn test_interpreted_response_headers_multiple() {
672
+ let mut headers = HashMap::new();
673
+ headers.insert("content-type".to_string(), "application/json".to_string());
674
+ headers.insert("cache-control".to_string(), "no-cache".to_string());
675
+ headers.insert("x-custom".to_string(), "value".to_string());
676
+
677
+ let response = InterpretedResponse::Custom {
678
+ status: 200,
679
+ headers,
680
+ body: None,
681
+ raw_body: None,
682
+ };
683
+
684
+ let resp_headers = response.headers().unwrap();
685
+ assert_eq!(resp_headers.len(), 3);
686
+ assert_eq!(resp_headers.get("content-type"), Some(&"application/json".to_string()));
687
+ assert_eq!(resp_headers.get("cache-control"), Some(&"no-cache".to_string()));
688
+ }
689
+
690
+ #[test]
691
+ fn test_interpreted_response_body_json() {
692
+ let body = serde_json::json!({
693
+ "id": 42,
694
+ "name": "test",
695
+ "nested": {
696
+ "value": true
697
+ }
698
+ });
699
+
700
+ let response = InterpretedResponse::Plain { body };
701
+
702
+ // Verify response preserves the value
703
+ match response {
704
+ InterpretedResponse::Plain {
705
+ body: ref returned_body,
706
+ } => {
707
+ assert_eq!(returned_body["id"], 42);
708
+ assert_eq!(returned_body["name"], "test");
709
+ assert_eq!(returned_body["nested"]["value"], true);
710
+ }
711
+ _ => panic!("Expected Plain response"),
712
+ }
713
+ }
714
+
715
+ #[test]
716
+ fn test_interpreted_response_raw_body_precedence() {
717
+ let response = InterpretedResponse::Custom {
718
+ status: 200,
719
+ headers: HashMap::new(),
720
+ body: Some(serde_json::json!({ "ignored": true })),
721
+ raw_body: Some(b"raw bytes".to_vec()),
722
+ };
723
+
724
+ match response {
725
+ InterpretedResponse::Custom {
726
+ body,
727
+ raw_body: Some(raw),
728
+ ..
729
+ } => {
730
+ assert!(body.is_some()); // body is still present
731
+ assert_eq!(raw, b"raw bytes");
732
+ }
733
+ _ => panic!("Expected Custom response with raw_body"),
734
+ }
735
+ }
736
+
737
+ #[test]
738
+ fn test_response_interpreter_streaming() {
739
+ let interpreter = TestInterpreter;
740
+ let result = interpreter.interpret(&TestValue::Stream).unwrap();
741
+
742
+ assert!(result.is_streaming());
743
+ assert_eq!(result.status(), 200);
744
+ }
745
+
746
+ #[test]
747
+ fn test_response_interpreter_custom() {
748
+ let interpreter = TestInterpreter;
749
+ let result = interpreter.interpret(&TestValue::Custom).unwrap();
750
+
751
+ assert!(result.is_custom());
752
+ assert_eq!(result.status(), 201);
753
+ assert_eq!(result.headers().unwrap().get("x-custom"), Some(&"header".to_string()));
754
+ }
755
+
756
+ #[test]
757
+ fn test_response_interpreter_plain() {
758
+ let interpreter = TestInterpreter;
759
+ let result = interpreter.interpret(&TestValue::Plain).unwrap();
760
+
761
+ assert!(result.is_plain());
762
+ assert_eq!(result.status(), 200);
763
+ }
764
+
765
+ #[test]
766
+ fn test_stream_source_multiple_chunks() {
767
+ let chunks = vec![b"first".to_vec(), b"second".to_vec(), b"third".to_vec()];
768
+ let mut stream = MockStreamSource::new(chunks);
769
+
770
+ assert_eq!(stream.next_chunk(), Some(b"first".to_vec()));
771
+ assert_eq!(stream.next_chunk(), Some(b"second".to_vec()));
772
+ assert_eq!(stream.next_chunk(), Some(b"third".to_vec()));
773
+ assert_eq!(stream.next_chunk(), None);
774
+ assert_eq!(stream.next_chunk(), None); // Idempotent
775
+ }
776
+
777
+ #[test]
778
+ fn test_stream_source_empty() {
779
+ let mut stream = MockStreamSource::new(vec![]);
780
+ assert_eq!(stream.next_chunk(), None);
781
+ }
782
+
783
+ #[test]
784
+ fn test_stream_source_large_chunks() {
785
+ let large_chunk = vec![0u8; 1024 * 1024]; // 1MB
786
+ let mut stream = MockStreamSource::new(vec![large_chunk]);
787
+
788
+ let retrieved = stream.next_chunk().unwrap();
789
+ assert_eq!(retrieved.len(), 1024 * 1024);
790
+ assert_eq!(stream.next_chunk(), None);
791
+ }
792
+
793
+ #[test]
794
+ fn test_streaming_response_headers_empty() {
795
+ let response = InterpretedResponse::Streaming {
796
+ enumerator: Box::new(MockStreamSource::new(vec![])),
797
+ status: 200,
798
+ headers: HashMap::new(),
799
+ };
800
+
801
+ let headers = response.headers().unwrap();
802
+ assert!(headers.is_empty());
803
+ }
804
+
805
+ #[test]
806
+ fn test_streaming_response_headers_with_values() {
807
+ let mut headers = HashMap::new();
808
+ headers.insert("transfer-encoding".to_string(), "chunked".to_string());
809
+ headers.insert("content-type".to_string(), "application/json".to_string());
810
+
811
+ let response = InterpretedResponse::Streaming {
812
+ enumerator: Box::new(MockStreamSource::new(vec![])),
813
+ status: 200,
814
+ headers,
815
+ };
816
+
817
+ let resp_headers = response.headers().unwrap();
818
+ assert_eq!(resp_headers.len(), 2);
819
+ assert_eq!(resp_headers.get("transfer-encoding"), Some(&"chunked".to_string()));
820
+ }
821
+
822
+ #[test]
823
+ fn test_custom_response_no_body() {
824
+ let response = InterpretedResponse::Custom {
825
+ status: 204,
826
+ headers: HashMap::new(),
827
+ body: None,
828
+ raw_body: None,
829
+ };
830
+
831
+ match response {
832
+ InterpretedResponse::Custom { body: None, .. } => {
833
+ // Expected: 204 No Content
834
+ }
835
+ _ => panic!("Expected Custom response with no body"),
836
+ }
837
+ }
838
+
839
+ #[test]
840
+ fn test_custom_response_json_body() {
841
+ let body = serde_json::json!({
842
+ "success": true,
843
+ "data": [1, 2, 3]
844
+ });
845
+
846
+ let response = InterpretedResponse::Custom {
847
+ status: 201,
848
+ headers: HashMap::new(),
849
+ body: Some(body),
850
+ raw_body: None,
851
+ };
852
+
853
+ match response {
854
+ InterpretedResponse::Custom {
855
+ status: 201,
856
+ body: Some(b),
857
+ ..
858
+ } => {
859
+ assert_eq!(b["success"], true);
860
+ assert_eq!(b["data"][0], 1);
861
+ }
862
+ _ => panic!("Expected Custom response with JSON body"),
863
+ }
864
+ }
865
+
866
+ #[test]
867
+ fn test_plain_response_various_json_types() {
868
+ // Test with object
869
+ let obj = InterpretedResponse::Plain {
870
+ body: serde_json::json!({ "key": "value" }),
871
+ };
872
+ assert!(obj.is_plain());
873
+
874
+ // Test with array
875
+ let arr = InterpretedResponse::Plain {
876
+ body: serde_json::json!([1, 2, 3]),
877
+ };
878
+ assert!(arr.is_plain());
879
+
880
+ // Test with string
881
+ let str_val = InterpretedResponse::Plain {
882
+ body: serde_json::json!("hello"),
883
+ };
884
+ assert!(str_val.is_plain());
885
+
886
+ // Test with number
887
+ let num = InterpretedResponse::Plain {
888
+ body: serde_json::json!(42),
889
+ };
890
+ assert!(num.is_plain());
891
+
892
+ // Test with null
893
+ let null_val = InterpretedResponse::Plain {
894
+ body: serde_json::Value::Null,
895
+ };
896
+ assert!(null_val.is_plain());
897
+ }
898
+
899
+ #[test]
900
+ fn test_stream_source_send_sync() {
901
+ // Verify that StreamSource trait objects are Send + Sync
902
+ let stream = MockStreamSource::new(vec![b"test".to_vec()]);
903
+ let boxed: Box<dyn StreamSource> = Box::new(stream);
904
+
905
+ // This compiles only if Box<dyn StreamSource> implements Send + Sync
906
+ let _: Box<dyn StreamSource + Send + Sync> = boxed;
907
+ }
908
+
909
+ #[test]
910
+ fn test_interpreted_response_debug() {
911
+ let plain = InterpretedResponse::Plain {
912
+ body: serde_json::json!({ "test": true }),
913
+ };
914
+
915
+ let debug_string = format!("{plain:?}");
916
+ assert!(debug_string.contains("Plain"));
917
+ }
918
+
919
+ #[test]
920
+ fn test_custom_response_with_all_fields() {
921
+ let mut headers = HashMap::new();
922
+ headers.insert("content-type".to_string(), "application/json".to_string());
923
+ headers.insert("x-custom".to_string(), "header-value".to_string());
924
+
925
+ let response = InterpretedResponse::Custom {
926
+ status: 200,
927
+ headers,
928
+ body: Some(serde_json::json!({ "data": "value" })),
929
+ raw_body: Some(b"fallback".to_vec()),
930
+ };
931
+
932
+ match response {
933
+ InterpretedResponse::Custom {
934
+ status: 200,
935
+ headers: h,
936
+ body: Some(_),
937
+ raw_body: Some(_),
938
+ } => {
939
+ assert_eq!(h.len(), 2);
940
+ }
941
+ _ => panic!("Expected Custom response with all fields"),
942
+ }
943
+ }
944
+ }