spikard 0.12.0 → 0.15.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 (206) hide show
  1. checksums.yaml +4 -4
  2. data/Steepfile +6 -0
  3. data/ext/spikard_rb/extconf.rb +1 -2
  4. data/ext/spikard_rb/{Cargo.lock → native/Cargo.lock} +897 -451
  5. data/ext/spikard_rb/native/Cargo.toml +24 -0
  6. data/ext/spikard_rb/src/lib.rs +5366 -3
  7. data/lib/spikard/native.rb +86 -0
  8. data/lib/spikard/version.rb +6 -1
  9. data/lib/spikard.rb +8 -45
  10. data/lib/spikard_rb.so +0 -0
  11. data/sig/types.rbs +427 -0
  12. metadata +14 -242
  13. data/LICENSE +0 -1
  14. data/README.md +0 -267
  15. data/ext/spikard_rb/Cargo.toml +0 -17
  16. data/lib/spikard/app.rb +0 -428
  17. data/lib/spikard/background.rb +0 -58
  18. data/lib/spikard/config.rb +0 -506
  19. data/lib/spikard/converters.rb +0 -13
  20. data/lib/spikard/grpc.rb +0 -182
  21. data/lib/spikard/handler_wrapper.rb +0 -113
  22. data/lib/spikard/provide.rb +0 -214
  23. data/lib/spikard/response.rb +0 -173
  24. data/lib/spikard/schema.rb +0 -243
  25. data/lib/spikard/sse.rb +0 -111
  26. data/lib/spikard/streaming_response.rb +0 -44
  27. data/lib/spikard/testing.rb +0 -432
  28. data/lib/spikard/upload_file.rb +0 -131
  29. data/lib/spikard/websocket.rb +0 -59
  30. data/sig/spikard.rbs +0 -719
  31. data/vendor/crates/spikard-bindings-shared/Cargo.toml +0 -80
  32. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +0 -132
  33. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +0 -905
  34. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +0 -210
  35. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +0 -252
  36. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +0 -404
  37. data/vendor/crates/spikard-bindings-shared/src/grpc_metadata.rs +0 -199
  38. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +0 -252
  39. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +0 -829
  40. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +0 -587
  41. data/vendor/crates/spikard-bindings-shared/src/lib.rs +0 -33
  42. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +0 -298
  43. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +0 -594
  44. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +0 -743
  45. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +0 -944
  46. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +0 -260
  47. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +0 -369
  48. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +0 -192
  49. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +0 -383
  50. data/vendor/crates/spikard-bindings-shared/tests/full_coverage.rs +0 -459
  51. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +0 -280
  52. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +0 -669
  53. data/vendor/crates/spikard-core/Cargo.toml +0 -60
  54. data/vendor/crates/spikard-core/src/bindings/mod.rs +0 -3
  55. data/vendor/crates/spikard-core/src/bindings/response.rs +0 -130
  56. data/vendor/crates/spikard-core/src/debug.rs +0 -127
  57. data/vendor/crates/spikard-core/src/di/container.rs +0 -702
  58. data/vendor/crates/spikard-core/src/di/dependency.rs +0 -273
  59. data/vendor/crates/spikard-core/src/di/error.rs +0 -118
  60. data/vendor/crates/spikard-core/src/di/factory.rs +0 -538
  61. data/vendor/crates/spikard-core/src/di/graph.rs +0 -507
  62. data/vendor/crates/spikard-core/src/di/mod.rs +0 -192
  63. data/vendor/crates/spikard-core/src/di/resolved.rs +0 -428
  64. data/vendor/crates/spikard-core/src/di/value.rs +0 -282
  65. data/vendor/crates/spikard-core/src/errors.rs +0 -72
  66. data/vendor/crates/spikard-core/src/http.rs +0 -492
  67. data/vendor/crates/spikard-core/src/lib.rs +0 -29
  68. data/vendor/crates/spikard-core/src/lifecycle.rs +0 -1273
  69. data/vendor/crates/spikard-core/src/metadata.rs +0 -378
  70. data/vendor/crates/spikard-core/src/parameters.rs +0 -2546
  71. data/vendor/crates/spikard-core/src/problem.rs +0 -358
  72. data/vendor/crates/spikard-core/src/request_data.rs +0 -1146
  73. data/vendor/crates/spikard-core/src/router.rs +0 -530
  74. data/vendor/crates/spikard-core/src/schema_registry.rs +0 -197
  75. data/vendor/crates/spikard-core/src/type_hints.rs +0 -311
  76. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +0 -710
  77. data/vendor/crates/spikard-core/src/validation/mod.rs +0 -470
  78. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +0 -136
  79. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +0 -37
  80. data/vendor/crates/spikard-core/tests/error_mapper.rs +0 -761
  81. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +0 -106
  82. data/vendor/crates/spikard-core/tests/parameters_full.rs +0 -701
  83. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +0 -301
  84. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +0 -67
  85. data/vendor/crates/spikard-core/tests/validation_coverage.rs +0 -250
  86. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +0 -45
  87. data/vendor/crates/spikard-http/Cargo.toml +0 -87
  88. data/vendor/crates/spikard-http/examples/sse-notifications.rs +0 -148
  89. data/vendor/crates/spikard-http/examples/websocket-chat.rs +0 -92
  90. data/vendor/crates/spikard-http/src/auth.rs +0 -301
  91. data/vendor/crates/spikard-http/src/background.rs +0 -1860
  92. data/vendor/crates/spikard-http/src/bindings/mod.rs +0 -3
  93. data/vendor/crates/spikard-http/src/bindings/response.rs +0 -1
  94. data/vendor/crates/spikard-http/src/body_metadata.rs +0 -8
  95. data/vendor/crates/spikard-http/src/cors.rs +0 -1026
  96. data/vendor/crates/spikard-http/src/debug.rs +0 -128
  97. data/vendor/crates/spikard-http/src/di_handler.rs +0 -1672
  98. data/vendor/crates/spikard-http/src/grpc/framing.rs +0 -469
  99. data/vendor/crates/spikard-http/src/grpc/handler.rs +0 -1122
  100. data/vendor/crates/spikard-http/src/grpc/mod.rs +0 -434
  101. data/vendor/crates/spikard-http/src/grpc/service.rs +0 -622
  102. data/vendor/crates/spikard-http/src/grpc/streaming.rs +0 -319
  103. data/vendor/crates/spikard-http/src/handler_response.rs +0 -901
  104. data/vendor/crates/spikard-http/src/handler_trait.rs +0 -1015
  105. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +0 -290
  106. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +0 -502
  107. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +0 -648
  108. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +0 -58
  109. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +0 -1207
  110. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +0 -2262
  111. data/vendor/crates/spikard-http/src/lib.rs +0 -548
  112. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +0 -230
  113. data/vendor/crates/spikard-http/src/lifecycle.rs +0 -1193
  114. data/vendor/crates/spikard-http/src/middleware/mod.rs +0 -560
  115. data/vendor/crates/spikard-http/src/middleware/multipart.rs +0 -912
  116. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +0 -513
  117. data/vendor/crates/spikard-http/src/middleware/validation.rs +0 -768
  118. data/vendor/crates/spikard-http/src/openapi/mod.rs +0 -309
  119. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +0 -535
  120. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +0 -1363
  121. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +0 -667
  122. data/vendor/crates/spikard-http/src/query_parser.rs +0 -793
  123. data/vendor/crates/spikard-http/src/response.rs +0 -720
  124. data/vendor/crates/spikard-http/src/server/fast_router.rs +0 -186
  125. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +0 -858
  126. data/vendor/crates/spikard-http/src/server/handler.rs +0 -1661
  127. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +0 -253
  128. data/vendor/crates/spikard-http/src/server/mod.rs +0 -1649
  129. data/vendor/crates/spikard-http/src/server/request_extraction.rs +0 -871
  130. data/vendor/crates/spikard-http/src/server/routing_factory.rs +0 -618
  131. data/vendor/crates/spikard-http/src/sse.rs +0 -1409
  132. data/vendor/crates/spikard-http/src/testing/form.rs +0 -52
  133. data/vendor/crates/spikard-http/src/testing/multipart.rs +0 -64
  134. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -787
  135. data/vendor/crates/spikard-http/src/testing.rs +0 -617
  136. data/vendor/crates/spikard-http/src/websocket.rs +0 -1477
  137. data/vendor/crates/spikard-http/tests/auth_integration.rs +0 -645
  138. data/vendor/crates/spikard-http/tests/background_behavior.rs +0 -832
  139. data/vendor/crates/spikard-http/tests/common/grpc_helpers.rs +0 -1012
  140. data/vendor/crates/spikard-http/tests/common/handlers.rs +0 -309
  141. data/vendor/crates/spikard-http/tests/common/mod.rs +0 -33
  142. data/vendor/crates/spikard-http/tests/common/test_builders.rs +0 -628
  143. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +0 -162
  144. data/vendor/crates/spikard-http/tests/di_integration.rs +0 -192
  145. data/vendor/crates/spikard-http/tests/doc_snippets.rs +0 -5
  146. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +0 -430
  147. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +0 -738
  148. data/vendor/crates/spikard-http/tests/grpc_error_handling_test.rs +0 -652
  149. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +0 -334
  150. data/vendor/crates/spikard-http/tests/grpc_metadata_test.rs +0 -532
  151. data/vendor/crates/spikard-http/tests/grpc_server_integration.rs +0 -495
  152. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +0 -974
  153. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +0 -1093
  154. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +0 -389
  155. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +0 -656
  156. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +0 -513
  157. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +0 -328
  158. data/vendor/crates/spikard-http/tests/server_config_builder.rs +0 -314
  159. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +0 -200
  160. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +0 -83
  161. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +0 -464
  162. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +0 -286
  163. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +0 -118
  164. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +0 -99
  165. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +0 -204
  166. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +0 -421
  167. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +0 -121
  168. data/vendor/crates/spikard-http/tests/sse_behavior.rs +0 -620
  169. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +0 -584
  170. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +0 -130
  171. data/vendor/crates/spikard-http/tests/test_client_requests.rs +0 -167
  172. data/vendor/crates/spikard-http/tests/testing_helpers.rs +0 -87
  173. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +0 -155
  174. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +0 -82
  175. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +0 -663
  176. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +0 -440
  177. data/vendor/crates/spikard-http/tests/websocket_integration.rs +0 -150
  178. data/vendor/crates/spikard-rb/Cargo.toml +0 -68
  179. data/vendor/crates/spikard-rb/build.rs +0 -200
  180. data/vendor/crates/spikard-rb/src/background.rs +0 -63
  181. data/vendor/crates/spikard-rb/src/config/mod.rs +0 -5
  182. data/vendor/crates/spikard-rb/src/config/server_config.rs +0 -401
  183. data/vendor/crates/spikard-rb/src/conversion.rs +0 -688
  184. data/vendor/crates/spikard-rb/src/di/builder.rs +0 -100
  185. data/vendor/crates/spikard-rb/src/di/mod.rs +0 -375
  186. data/vendor/crates/spikard-rb/src/grpc/handler.rs +0 -834
  187. data/vendor/crates/spikard-rb/src/grpc/mod.rs +0 -13
  188. data/vendor/crates/spikard-rb/src/gvl.rs +0 -80
  189. data/vendor/crates/spikard-rb/src/handler.rs +0 -699
  190. data/vendor/crates/spikard-rb/src/integration/mod.rs +0 -3
  191. data/vendor/crates/spikard-rb/src/lib.rs +0 -2264
  192. data/vendor/crates/spikard-rb/src/lifecycle.rs +0 -303
  193. data/vendor/crates/spikard-rb/src/metadata/mod.rs +0 -5
  194. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +0 -507
  195. data/vendor/crates/spikard-rb/src/request.rs +0 -439
  196. data/vendor/crates/spikard-rb/src/runtime/mod.rs +0 -5
  197. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +0 -344
  198. data/vendor/crates/spikard-rb/src/server.rs +0 -307
  199. data/vendor/crates/spikard-rb/src/sse.rs +0 -231
  200. data/vendor/crates/spikard-rb/src/testing/client.rs +0 -698
  201. data/vendor/crates/spikard-rb/src/testing/mod.rs +0 -7
  202. data/vendor/crates/spikard-rb/src/testing/sse.rs +0 -108
  203. data/vendor/crates/spikard-rb/src/testing/websocket.rs +0 -573
  204. data/vendor/crates/spikard-rb/src/websocket.rs +0 -475
  205. data/vendor/crates/spikard-rb-macros/Cargo.toml +0 -25
  206. data/vendor/crates/spikard-rb-macros/src/lib.rs +0 -51
@@ -1,2264 +0,0 @@
1
- #![allow(deprecated)]
2
- #![deny(clippy::unwrap_used)]
3
- #![allow(clippy::similar_names)] // Common in FFI code
4
- #![allow(clippy::missing_errors_doc)] // Many FFI functions return Result
5
- #![allow(clippy::doc_markdown)] // FFI types don't need backticks
6
- #![allow(clippy::missing_const_for_fn)] // FFI functions can't be const
7
- #![allow(clippy::too_many_arguments)] // FFI bridge functions often need many parameters
8
- #![allow(clippy::too_many_lines)] // FFI wrappers accumulate code
9
- #![allow(clippy::unused_self)] // Magnus methods may not use self
10
- #![allow(clippy::unnecessary_wraps)] // Magnus patterns require Result wrappers
11
- #![allow(clippy::must_use_candidate)] // FFI constructors follow Rust patterns
12
- #![allow(clippy::struct_excessive_bools)] // FFI configs use multiple bools
13
- #![allow(clippy::fn_params_excessive_bools)] // FFI builders pass multiple bools
14
- #![allow(clippy::items_after_statements)] // Common in Rust code
15
- #![allow(clippy::if_not_else)] // FFI code style preference
16
- #![allow(clippy::redundant_clone)] // May be necessary in FFI boundary
17
- #![allow(clippy::uninlined_format_args)] // FFI error messages
18
- #![allow(clippy::cognitive_complexity)] // FFI handlers have complex logic
19
- #![allow(clippy::cast_lossless)] // Type conversions in FFI
20
- #![allow(clippy::option_if_let_else)] // FFI error handling patterns
21
- #![allow(clippy::missing_panics_doc)] // Runtime server panics acceptable in server context
22
- #![allow(clippy::unused_async)] // Async trait methods may not await
23
- #![allow(clippy::non_std_lazy_statics)] // using_once_cell pattern
24
- #![allow(clippy::ptr_as_ptr)] // Raw pointer casts in FFI code
25
- #![allow(clippy::ptr_cast_constness)] // Cast constness for FFI interop
26
- #![allow(clippy::significant_drop_tightening)] // Drop timing in FFI bridges
27
- #![allow(clippy::trivially_copy_pass_by_ref)] // FFI compatibility
28
- #![allow(clippy::cast_possible_wrap)] // Cast wrapping in FFI
29
- #![allow(clippy::cast_possible_truncation)] // Type size differences in FFI
30
- #![allow(clippy::used_underscore_binding)] // Internal FFI code
31
- #![allow(clippy::redundant_closure)] // FFI closure patterns
32
- #![allow(clippy::explicit_iter_loop)] // FFI iteration style
33
- #![allow(clippy::cast_sign_loss)] // Unsigned/signed casts in FFI
34
- #![allow(clippy::map_unwrap_or)] // Idiomatic Option/Result handling
35
- #![allow(clippy::implicit_clone)] // String conversions in FFI
36
- #![allow(clippy::ref_option_ref)] // Reference patterns in FFI
37
- #![allow(clippy::should_implement_trait)] // FFI trait implementation
38
- #![allow(clippy::match_like_matches_macro)] // FFI match patterns
39
- #![allow(clippy::match_bool)] // Boolean matching in FFI
40
- #![allow(clippy::format_push_string)] // String formatting in FFI
41
- #![allow(clippy::option_option)] // Option nesting in FFI
42
- #![allow(clippy::enum_variant_names)] // FFI variant naming
43
- #![allow(clippy::identity_op)] // FFI operations
44
- #![allow(clippy::filter_next)] // Filter operations in FFI
45
- #![allow(clippy::manual_let_else)] // Let-else patterns in FFI
46
- #![allow(clippy::if_then_some_else_none)] // If-then-some patterns
47
- #![allow(clippy::clone_on_copy)] // Clone on copy types in FFI
48
- #![allow(clippy::unit_arg)] // Unit argument handling
49
- #![allow(clippy::impl_trait_in_params)] // Trait parameters in FFI
50
- #![allow(clippy::match_same_arms)] // Identical match arms
51
- #![allow(clippy::needless_pass_by_value)] // FFI argument passing style
52
- #![allow(clippy::ref_as_ptr)] // Explicit pointer casts in FFI
53
- #![allow(clippy::while_let_on_iterator)] // Iterator patterns in FFI
54
- #![allow(clippy::redundant_closure_for_method_calls)] // Closure patterns in FFI
55
- #![allow(clippy::as_ptr_cast_mut)] // Raw pointer casting in FFI
56
- #![allow(clippy::match_wildcard_for_single_variants)] // Wildcard patterns in FFI
57
- #![allow(clippy::ignored_unit_patterns)] // Unit pattern handling in FFI
58
- #![allow(clippy::option_as_ref_deref)] // Option reference patterns
59
- #![allow(clippy::semicolon_if_nothing_returned)] // Return statement consistency
60
- #![allow(clippy::map_identity)] // Identity mapping patterns
61
-
62
- //! Spikard Ruby bindings using Magnus FFI.
63
- //!
64
- //! This crate provides Ruby bindings for the Spikard HTTP toolkit, allowing
65
- //! Ruby developers to build and test HTTP services with Rust performance.
66
- //!
67
- //! ## Modules
68
- //!
69
- //! - `testing`: Testing utilities (client, SSE, WebSocket)
70
- //! - `handler`: RubyHandler trait implementation
71
- //! - `di`: Dependency injection bridge for Ruby types
72
- //! - `config`: ServerConfig extraction from Ruby objects
73
- //! - `conversion`: Ruby ↔ Rust type conversions
74
- //! - `server`: HTTP server setup and lifecycle management
75
- //! - `background`: Background task management
76
- //! - `lifecycle`: Lifecycle hook implementations
77
- //! - `sse`: Server-Sent Events support
78
- //! - `websocket`: WebSocket support
79
- //! - `grpc`: gRPC handler support
80
-
81
- mod background;
82
- mod config;
83
- mod conversion;
84
- mod di;
85
- mod grpc;
86
- mod gvl;
87
- mod handler;
88
- mod integration;
89
- mod lifecycle;
90
- mod metadata;
91
- mod request;
92
- mod runtime;
93
- mod server;
94
- mod sse;
95
- mod testing;
96
- mod websocket;
97
-
98
- use async_stream::stream;
99
- use axum::body::Body;
100
- use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
101
- use axum_test::{TestServer, TestServerConfig, Transport};
102
- use bytes::Bytes;
103
- use magnus::prelude::*;
104
- use magnus::value::{InnerValue, Opaque};
105
- use magnus::{
106
- Error, Module, RArray, RHash, RString, Ruby, TryConvert, Value, function, gc::Marker, method, r_hash::ForEach,
107
- };
108
- use serde_json::Value as JsonValue;
109
- use spikard_bindings_shared::ErrorResponseBuilder;
110
- use spikard_http::ProblemDetails;
111
- use spikard_http::testing::ResponseSnapshot;
112
- use spikard_http::{Handler, HandlerResponse, HandlerResult, RequestData};
113
- use spikard_http::{Route, RouteMetadata, SchemaValidator};
114
- use std::cell::RefCell;
115
- use std::collections::HashMap;
116
- use std::io;
117
- use std::mem;
118
- use std::panic::AssertUnwindSafe;
119
- use std::pin::Pin;
120
- use std::sync::Arc;
121
- use std::time::Duration;
122
- use url::Url;
123
-
124
- use crate::config::extract_server_config;
125
- use crate::conversion::{extract_files, problem_to_json};
126
- use crate::gvl::with_gvl;
127
- use crate::integration::build_dependency_container;
128
- use crate::metadata::{build_route_metadata, ruby_value_to_json};
129
- use crate::request::NativeRequest;
130
- use crate::runtime::{normalize_route_metadata, run_server};
131
-
132
- #[derive(Default)]
133
- #[magnus::wrap(class = "Spikard::Native::TestClient", free_immediately, mark)]
134
- struct NativeTestClient {
135
- inner: RefCell<Option<ClientInner>>,
136
- }
137
-
138
- struct ClientInner {
139
- http_server: Arc<TestServer>,
140
- transport_server: Option<Arc<TestServer>>,
141
- /// Keep Ruby handler closures alive for GC; accessed via the `mark` hook.
142
- _handlers: Vec<RubyHandler>,
143
- }
144
-
145
- // Re-export from testing::client to avoid duplication
146
- use testing::client::{RequestBody, RequestConfig};
147
-
148
- #[derive(Clone)]
149
- struct RubyHandler {
150
- inner: Arc<RubyHandlerInner>,
151
- }
152
-
153
- struct RubyHandlerInner {
154
- handler_proc: Opaque<Value>,
155
- handler_name: String,
156
- json_module: Opaque<Value>,
157
- response_validator: Option<Arc<SchemaValidator>>,
158
- #[cfg(feature = "di")]
159
- handler_dependencies: Vec<String>,
160
- }
161
-
162
- struct HandlerResponsePayload {
163
- status: u16,
164
- headers: HashMap<String, String>,
165
- body: Option<JsonValue>,
166
- raw_body: Option<Vec<u8>>,
167
- }
168
-
169
- struct NativeResponseParts {
170
- response: HandlerResponse,
171
- body_json: Option<JsonValue>,
172
- }
173
-
174
- enum RubyHandlerResult {
175
- Payload(HandlerResponsePayload),
176
- Streaming(StreamingResponsePayload),
177
- Native(NativeResponseParts),
178
- }
179
-
180
- struct StreamingResponsePayload {
181
- enumerator: Arc<Opaque<Value>>,
182
- status: u16,
183
- headers: HashMap<String, String>,
184
- }
185
-
186
- #[magnus::wrap(class = "Spikard::Native::BuiltResponse", free_immediately, mark)]
187
- struct NativeBuiltResponse {
188
- response: RefCell<Option<HandlerResponse>>,
189
- body_json: Option<JsonValue>,
190
- /// Ruby values that must be kept alive for GC (e.g., streaming enumerators)
191
- #[allow(dead_code)]
192
- gc_handles: Vec<Opaque<Value>>,
193
- }
194
-
195
- #[derive(Default)]
196
- #[magnus::wrap(class = "Spikard::Native::LifecycleRegistry", free_immediately, mark)]
197
- struct NativeLifecycleRegistry {
198
- hooks: RefCell<spikard_http::LifecycleHooks>,
199
- ruby_hooks: RefCell<Vec<Arc<crate::lifecycle::RubyLifecycleHook>>>,
200
- }
201
-
202
- #[magnus::wrap(class = "Spikard::Native::DependencyRegistry", free_immediately, mark)]
203
- struct NativeDependencyRegistry {
204
- container: RefCell<Option<spikard_core::di::DependencyContainer>>,
205
- #[allow(dead_code)]
206
- gc_handles: RefCell<Vec<Opaque<Value>>>,
207
- registered_keys: RefCell<Vec<String>>,
208
- registered_dependencies: RefCell<Vec<(String, Arc<dyn spikard_core::di::Dependency>)>>,
209
- }
210
-
211
- impl StreamingResponsePayload {
212
- fn into_response(self) -> Result<HandlerResponse, Error> {
213
- let ruby = Ruby::get().map_err(|_| {
214
- Error::new(
215
- magnus::exception::runtime_error(),
216
- "Ruby VM became unavailable during streaming response construction",
217
- )
218
- })?;
219
-
220
- let status = StatusCode::from_u16(self.status).map_err(|err| {
221
- Error::new(
222
- ruby.exception_arg_error(),
223
- format!("Invalid streaming status code {}: {}", self.status, err),
224
- )
225
- })?;
226
-
227
- let header_pairs = self
228
- .headers
229
- .into_iter()
230
- .map(|(name, value)| {
231
- let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
232
- Error::new(
233
- ruby.exception_arg_error(),
234
- format!("Invalid header name '{name}': {err}"),
235
- )
236
- })?;
237
- let header_value = HeaderValue::from_str(&value).map_err(|err| {
238
- Error::new(
239
- ruby.exception_arg_error(),
240
- format!("Invalid header value for '{name}': {err}"),
241
- )
242
- })?;
243
- Ok((header_name, header_value))
244
- })
245
- .collect::<Result<Vec<_>, Error>>()?;
246
-
247
- let enumerator = self.enumerator.clone();
248
- let body_stream = stream! {
249
- loop {
250
- match poll_stream_chunk(&enumerator) {
251
- Ok(Some(bytes)) => yield Ok(bytes),
252
- Ok(None) => break,
253
- Err(err) => {
254
- yield Err(Box::new(err));
255
- break;
256
- }
257
- }
258
- }
259
- };
260
-
261
- let mut response = HandlerResponse::stream(body_stream).with_status(status);
262
- for (name, value) in header_pairs {
263
- response = response.with_header(name, value);
264
- }
265
- Ok(response)
266
- }
267
- }
268
-
269
- impl NativeBuiltResponse {
270
- #[allow(dead_code)]
271
- fn new(response: HandlerResponse, body_json: Option<JsonValue>, gc_handles: Vec<Opaque<Value>>) -> Self {
272
- Self {
273
- response: RefCell::new(Some(response)),
274
- body_json,
275
- gc_handles,
276
- }
277
- }
278
-
279
- fn extract_parts(&self) -> Result<(HandlerResponse, Option<JsonValue>), Error> {
280
- let mut borrow = self.response.borrow_mut();
281
- let response = borrow
282
- .take()
283
- .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "Native response already consumed"))?;
284
- Ok((response, self.body_json.clone()))
285
- }
286
-
287
- fn status_code(&self) -> u16 {
288
- let borrow = self.response.borrow();
289
- let Some(response) = borrow.as_ref() else {
290
- return StatusCode::OK.as_u16();
291
- };
292
-
293
- match response {
294
- HandlerResponse::Response(resp) => resp.status().as_u16(),
295
- HandlerResponse::Stream { status, .. } => status.as_u16(),
296
- }
297
- }
298
-
299
- fn headers(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
300
- let headers_hash = ruby.hash_new();
301
- if let Some(response) = this.response.borrow().as_ref() {
302
- match response {
303
- HandlerResponse::Response(resp) => {
304
- for (header_name, value) in resp.headers() {
305
- let name = header_name.as_str();
306
- if let Ok(value_str) = value.to_str() {
307
- headers_hash.aset(ruby.str_new(name), ruby.str_new(value_str))?;
308
- }
309
- }
310
- }
311
- HandlerResponse::Stream { headers, .. } => {
312
- for (header_name, value) in headers.iter() {
313
- let name = header_name.as_str();
314
- if let Ok(value_str) = value.to_str() {
315
- headers_hash.aset(ruby.str_new(name), ruby.str_new(value_str))?;
316
- }
317
- }
318
- }
319
- }
320
- }
321
- Ok(headers_hash.as_value())
322
- }
323
-
324
- #[allow(dead_code)]
325
- fn mark(&self, marker: &Marker) {
326
- if let Ok(ruby) = Ruby::get() {
327
- for handle in &self.gc_handles {
328
- marker.mark(handle.get_inner_with(&ruby));
329
- }
330
- }
331
- }
332
- }
333
-
334
- impl NativeLifecycleRegistry {
335
- fn add_on_request(&self, hook_value: Value) -> Result<(), Error> {
336
- self.add_hook("on_request", hook_value, |hooks, hook| hooks.add_on_request(hook))
337
- }
338
-
339
- fn add_pre_validation(&self, hook_value: Value) -> Result<(), Error> {
340
- self.add_hook("pre_validation", hook_value, |hooks, hook| {
341
- hooks.add_pre_validation(hook)
342
- })
343
- }
344
-
345
- fn add_pre_handler(&self, hook_value: Value) -> Result<(), Error> {
346
- self.add_hook("pre_handler", hook_value, |hooks, hook| hooks.add_pre_handler(hook))
347
- }
348
-
349
- fn add_on_response(&self, hook_value: Value) -> Result<(), Error> {
350
- self.add_hook("on_response", hook_value, |hooks, hook| hooks.add_on_response(hook))
351
- }
352
-
353
- fn add_on_error(&self, hook_value: Value) -> Result<(), Error> {
354
- self.add_hook("on_error", hook_value, |hooks, hook| hooks.add_on_error(hook))
355
- }
356
-
357
- fn take_hooks(&self) -> spikard_http::LifecycleHooks {
358
- mem::take(&mut *self.hooks.borrow_mut())
359
- }
360
-
361
- #[allow(dead_code)]
362
- fn mark(&self, marker: &Marker) {
363
- for hook in self.ruby_hooks.borrow().iter() {
364
- hook.mark(marker);
365
- }
366
- }
367
-
368
- fn add_hook<F>(&self, kind: &str, hook_value: Value, push: F) -> Result<(), Error>
369
- where
370
- F: Fn(&mut spikard_http::LifecycleHooks, Arc<crate::lifecycle::RubyLifecycleHook>),
371
- {
372
- let ruby = Ruby::get().map_err(|err| Error::new(magnus::exception::runtime_error(), err.to_string()))?;
373
- let hook_value = crate::conversion::ensure_callable(&ruby, hook_value, kind)?;
374
- let idx = self.ruby_hooks.borrow().len();
375
- let hook = Arc::new(crate::lifecycle::RubyLifecycleHook::new(
376
- format!("{kind}_{idx}"),
377
- hook_value,
378
- ));
379
-
380
- push(&mut self.hooks.borrow_mut(), hook.clone());
381
- self.ruby_hooks.borrow_mut().push(hook);
382
- Ok(())
383
- }
384
- }
385
-
386
- impl Default for NativeDependencyRegistry {
387
- fn default() -> Self {
388
- Self {
389
- container: RefCell::new(Some(spikard_core::di::DependencyContainer::new())),
390
- gc_handles: RefCell::new(Vec::new()),
391
- registered_keys: RefCell::new(Vec::new()),
392
- registered_dependencies: RefCell::new(Vec::new()),
393
- }
394
- }
395
- }
396
-
397
- impl NativeDependencyRegistry {
398
- fn register_value(ruby: &Ruby, this: &Self, key: String, value: Value) -> Result<(), Error> {
399
- let dependency = crate::di::RubyValueDependency::new(key.clone(), value);
400
- this.register_dependency(ruby, key, Arc::new(dependency), Some(value))
401
- }
402
-
403
- fn register_factory(
404
- ruby: &Ruby,
405
- this: &Self,
406
- key: String,
407
- factory: Value,
408
- depends_on: Value,
409
- singleton: bool,
410
- cacheable: bool,
411
- ) -> Result<(), Error> {
412
- let depends_on_vec = if depends_on.is_nil() {
413
- Vec::new()
414
- } else {
415
- Vec::<String>::try_convert(depends_on)?
416
- };
417
-
418
- let dependency =
419
- crate::di::RubyFactoryDependency::new(key.clone(), factory, depends_on_vec, singleton, cacheable);
420
- this.register_dependency(ruby, key, Arc::new(dependency), Some(factory))
421
- }
422
-
423
- fn register_dependency(
424
- &self,
425
- ruby: &Ruby,
426
- key: String,
427
- dependency: Arc<dyn spikard_core::di::Dependency>,
428
- gc_value: Option<Value>,
429
- ) -> Result<(), Error> {
430
- let mut container_ref = self.container.borrow_mut();
431
- let container = container_ref
432
- .as_mut()
433
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Dependency container already consumed"))?;
434
- let dependency_clone = dependency.clone();
435
-
436
- container.register(key.clone(), dependency).map_err(|err| {
437
- Error::new(
438
- ruby.exception_runtime_error(),
439
- format!("Failed to register dependency '{key}': {err}"),
440
- )
441
- })?;
442
-
443
- if let Some(val) = gc_value {
444
- self.gc_handles.borrow_mut().push(Opaque::from(val));
445
- }
446
-
447
- self.registered_keys.borrow_mut().push(key.clone());
448
- self.registered_dependencies
449
- .borrow_mut()
450
- .push((key.clone(), dependency_clone));
451
-
452
- Ok(())
453
- }
454
-
455
- #[allow(dead_code)]
456
- fn mark(&self, marker: &Marker) {
457
- if let Ok(ruby) = Ruby::get() {
458
- for handle in self.gc_handles.borrow().iter() {
459
- marker.mark(handle.get_inner_with(&ruby));
460
- }
461
- }
462
- }
463
-
464
- fn take_container(&self) -> Result<spikard_core::di::DependencyContainer, Error> {
465
- let mut borrow = self.container.borrow_mut();
466
- let container = borrow.take().ok_or_else(|| {
467
- Error::new(
468
- magnus::exception::runtime_error(),
469
- "Dependency container already consumed",
470
- )
471
- })?;
472
- Ok(container)
473
- }
474
-
475
- fn clone_container(&self, ruby: &Ruby) -> Result<spikard_core::di::DependencyContainer, Error> {
476
- let mut container = spikard_core::di::DependencyContainer::new();
477
- for (key, dependency) in self.registered_dependencies.borrow().iter() {
478
- container.register(key.clone(), dependency.clone()).map_err(|err| {
479
- Error::new(
480
- ruby.exception_runtime_error(),
481
- format!("Failed to clone dependency '{key}': {err}"),
482
- )
483
- })?;
484
- }
485
- Ok(container)
486
- }
487
-
488
- fn keys(&self) -> Vec<String> {
489
- self.registered_keys.borrow().clone()
490
- }
491
-
492
- fn resolve(ruby: &Ruby, this: &Self, key: String) -> Result<Value, Error> {
493
- let registered = this.registered_keys.borrow();
494
- if registered.contains(&key) {
495
- Ok(ruby.qnil().as_value())
496
- } else {
497
- Err(Error::new(
498
- ruby.exception_runtime_error(),
499
- format!("Failed to resolve dependency '{}': key '{}' not registered", key, key),
500
- ))
501
- }
502
- }
503
- }
504
-
505
- fn poll_stream_chunk(enumerator: &Arc<Opaque<Value>>) -> Result<Option<Bytes>, io::Error> {
506
- let ruby = Ruby::get().map_err(|err| io::Error::other(err.to_string()))?;
507
- let enum_value = enumerator.get_inner_with(&ruby);
508
- match enum_value.funcall::<_, _, Value>("next", ()) {
509
- Ok(chunk) => ruby_value_to_bytes(chunk).map(Some),
510
- Err(err) => {
511
- if err.is_kind_of(ruby.exception_stop_iteration()) {
512
- Ok(None)
513
- } else {
514
- Err(io::Error::other(err.to_string()))
515
- }
516
- }
517
- }
518
- }
519
-
520
- fn ruby_value_to_bytes(value: Value) -> Result<Bytes, io::Error> {
521
- if let Ok(str_value) = RString::try_convert(value) {
522
- let slice = unsafe { str_value.as_slice() };
523
- return Ok(Bytes::copy_from_slice(slice));
524
- }
525
-
526
- if let Ok(vec_bytes) = Vec::<u8>::try_convert(value) {
527
- return Ok(Bytes::from(vec_bytes));
528
- }
529
-
530
- Err(io::Error::other("Streaming chunks must be Strings or Arrays of bytes"))
531
- }
532
-
533
- impl NativeTestClient {
534
- #[allow(clippy::too_many_arguments)]
535
- fn initialize(
536
- ruby: &Ruby,
537
- this: &Self,
538
- routes_json: String,
539
- handlers: Value,
540
- config_value: Value,
541
- ws_handlers: Value,
542
- sse_producers: Value,
543
- dependencies: Value,
544
- ) -> Result<(), Error> {
545
- let (hooks_value, dependencies_value) = if let Some(arg_hash) = RHash::from_value(dependencies) {
546
- let hooks_value = arg_hash.get(ruby.to_symbol("hooks")).or_else(|| arg_hash.get("hooks"));
547
- let dependencies_value = arg_hash
548
- .get(ruby.to_symbol("dependencies"))
549
- .or_else(|| arg_hash.get("dependencies"));
550
-
551
- if hooks_value.is_some() || dependencies_value.is_some() {
552
- (
553
- hooks_value.unwrap_or_else(|| ruby.qnil().as_value()),
554
- dependencies_value.unwrap_or_else(|| ruby.qnil().as_value()),
555
- )
556
- } else {
557
- (ruby.qnil().as_value(), dependencies)
558
- }
559
- } else {
560
- (ruby.qnil().as_value(), dependencies)
561
- };
562
- let metadata: Vec<RouteMetadata> = serde_json::from_str(&routes_json)
563
- .map_err(|err| Error::new(ruby.exception_arg_error(), format!("Invalid routes JSON: {err}")))?;
564
-
565
- let handlers_hash = RHash::from_value(handlers).ok_or_else(|| {
566
- Error::new(
567
- ruby.exception_arg_error(),
568
- "handlers parameter must be a Hash of handler_name => Proc",
569
- )
570
- })?;
571
-
572
- let json_module = ruby
573
- .class_object()
574
- .const_get("JSON")
575
- .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
576
-
577
- let mut server_config = extract_server_config(ruby, config_value)?;
578
-
579
- #[cfg(feature = "di")]
580
- {
581
- if let Ok(registry) = <&NativeDependencyRegistry>::try_convert(dependencies_value) {
582
- server_config.di_container = Some(Arc::new(registry.clone_container(ruby)?));
583
- } else if !dependencies_value.is_nil() {
584
- match build_dependency_container(ruby, dependencies_value) {
585
- Ok(container) => {
586
- server_config.di_container = Some(Arc::new(container));
587
- }
588
- Err(err) => {
589
- return Err(Error::new(
590
- ruby.exception_runtime_error(),
591
- format!("Failed to build DI container: {}", err),
592
- ));
593
- }
594
- }
595
- }
596
- }
597
-
598
- let lifecycle_hooks = if let Ok(registry) = <&NativeLifecycleRegistry>::try_convert(hooks_value) {
599
- Some(registry.take_hooks())
600
- } else if !hooks_value.is_nil() {
601
- let hooks_hash = RHash::from_value(hooks_value)
602
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "lifecycle_hooks parameter must be a Hash"))?;
603
-
604
- let mut hooks = spikard_http::LifecycleHooks::new();
605
- type RubyHook = Arc<
606
- dyn spikard_http::lifecycle::LifecycleHook<
607
- axum::http::Request<axum::body::Body>,
608
- axum::http::Response<axum::body::Body>,
609
- >,
610
- >;
611
-
612
- let extract_hooks = |key: &str| -> Result<Vec<RubyHook>, Error> {
613
- let key_sym = ruby.to_symbol(key);
614
- if let Some(hooks_array) = hooks_hash.get(key_sym)
615
- && !hooks_array.is_nil()
616
- {
617
- let array = magnus::RArray::from_value(hooks_array)
618
- .ok_or_else(|| Error::new(ruby.exception_type_error(), format!("{} must be an Array", key)))?;
619
-
620
- let mut result = Vec::new();
621
- let len = array.len();
622
- for i in 0..len {
623
- let hook_value: Value = array.entry(i as isize)?;
624
- let name = format!("{}_{}", key, i);
625
- let ruby_hook = crate::lifecycle::RubyLifecycleHook::new(name, hook_value);
626
- result.push(Arc::new(ruby_hook) as RubyHook);
627
- }
628
- Ok(result)
629
- } else {
630
- Ok(Vec::new())
631
- }
632
- };
633
-
634
- for hook in extract_hooks("on_request")? {
635
- hooks.add_on_request(hook);
636
- }
637
- for hook in extract_hooks("pre_validation")? {
638
- hooks.add_pre_validation(hook);
639
- }
640
- for hook in extract_hooks("pre_handler")? {
641
- hooks.add_pre_handler(hook);
642
- }
643
- for hook in extract_hooks("on_response")? {
644
- hooks.add_on_response(hook);
645
- }
646
- for hook in extract_hooks("on_error")? {
647
- hooks.add_on_error(hook);
648
- }
649
-
650
- Some(hooks)
651
- } else {
652
- None
653
- };
654
-
655
- if let Some(hooks) = lifecycle_hooks {
656
- server_config.lifecycle_hooks = Some(Arc::new(hooks));
657
- }
658
-
659
- let schema_registry = spikard_http::SchemaRegistry::new();
660
- let mut prepared_routes = Vec::with_capacity(metadata.len());
661
- let mut handler_refs = Vec::with_capacity(metadata.len());
662
- let mut route_metadata_vec = Vec::with_capacity(metadata.len());
663
-
664
- for meta in metadata.clone() {
665
- validate_route_metadata(ruby, &meta)?;
666
- let route = Route::from_metadata(meta.clone(), &schema_registry)
667
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build route: {err}")))?;
668
- let handler_value = fetch_handler(ruby, &handlers_hash, &meta.handler_name)?;
669
-
670
- let handler = RubyHandler::new(&route, handler_value, json_module)?;
671
- prepared_routes.push((route, Arc::new(handler.clone()) as Arc<dyn spikard_http::Handler>));
672
- handler_refs.push(handler);
673
- route_metadata_vec.push(meta);
674
- }
675
-
676
- let mut router = spikard_http::server::build_router_with_handlers_and_config(
677
- prepared_routes,
678
- server_config,
679
- route_metadata_vec,
680
- )
681
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("Failed to build router: {err}")))?;
682
-
683
- let mut ws_endpoints = Vec::new();
684
- if !ws_handlers.is_nil() {
685
- let ws_hash = RHash::from_value(ws_handlers)
686
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "WebSocket handlers must be a Hash"))?;
687
-
688
- ws_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
689
- if let Some(ws_state) = crate::websocket::create_websocket_state(ruby, factory)? {
690
- ws_endpoints.push((path, ws_state));
691
- }
692
-
693
- Ok(ForEach::Continue)
694
- })?;
695
- }
696
-
697
- let mut sse_endpoints = Vec::new();
698
- if !sse_producers.is_nil() {
699
- let sse_hash = RHash::from_value(sse_producers)
700
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "SSE producers must be a Hash"))?;
701
-
702
- sse_hash.foreach(|path: String, factory: Value| -> Result<ForEach, Error> {
703
- let producer_instance = factory.funcall::<_, _, Value>("call", ()).map_err(|e| {
704
- Error::new(
705
- ruby.exception_runtime_error(),
706
- format!("Failed to create SSE producer: {}", e),
707
- )
708
- })?;
709
-
710
- let sse_state = crate::sse::create_sse_state(ruby, producer_instance)?;
711
-
712
- sse_endpoints.push((path, sse_state));
713
-
714
- Ok(ForEach::Continue)
715
- })?;
716
- }
717
-
718
- let has_ws = !ws_endpoints.is_empty();
719
-
720
- use axum::routing::get;
721
- for (path, ws_state) in ws_endpoints {
722
- router = router.route(
723
- &path,
724
- get(spikard_http::websocket_handler::<crate::websocket::RubyWebSocketHandler>).with_state(ws_state),
725
- );
726
- }
727
-
728
- for (path, sse_state) in sse_endpoints {
729
- router = router.route(
730
- &path,
731
- get(spikard_http::sse_handler::<crate::sse::RubySseEventProducer>).with_state(sse_state),
732
- );
733
- }
734
-
735
- let runtime = crate::server::global_runtime(ruby)?;
736
- let http_server = runtime.block_on(async { TestServer::new(router.clone()) });
737
-
738
- let transport_server = if has_ws {
739
- let ws_config = TestServerConfig {
740
- transport: Some(Transport::HttpRandomPort),
741
- ..Default::default()
742
- };
743
- let server = runtime.block_on(async { TestServer::new_with_config(router, ws_config) });
744
- Some(Arc::new(server))
745
- } else {
746
- None
747
- };
748
-
749
- *this.inner.borrow_mut() = Some(ClientInner {
750
- http_server: Arc::new(http_server),
751
- transport_server,
752
- _handlers: handler_refs,
753
- });
754
-
755
- Ok(())
756
- }
757
-
758
- fn request(ruby: &Ruby, this: &Self, method: String, path: String, options: Value) -> Result<Value, Error> {
759
- let inner_borrow = this.inner.borrow();
760
- let inner = inner_borrow
761
- .as_ref()
762
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
763
- let method_upper = method.to_ascii_uppercase();
764
- let http_method = Method::from_bytes(method_upper.as_bytes()).map_err(|err| {
765
- Error::new(
766
- ruby.exception_arg_error(),
767
- format!("Unsupported method {method_upper}: {err}"),
768
- )
769
- })?;
770
-
771
- let request_config = parse_request_config(ruby, options)?;
772
-
773
- let runtime = crate::server::global_runtime(ruby)?;
774
- let server = inner.http_server.clone();
775
- let path_value = path.clone();
776
- let response = crate::call_without_gvl!(
777
- testing::client::block_on_request,
778
- args: (
779
- runtime, &tokio::runtime::Runtime,
780
- server, Arc<TestServer>,
781
- http_method, Method,
782
- path_value, String,
783
- request_config, testing::client::RequestConfig
784
- ),
785
- return_type: Result<testing::client::TestResponseData, testing::client::NativeRequestError>
786
- )
787
- .map_err(|err| {
788
- Error::new(
789
- ruby.exception_runtime_error(),
790
- format!("Request failed for {method_upper} {path}: {}", err.0),
791
- )
792
- })?;
793
-
794
- response_to_ruby(ruby, response)
795
- }
796
-
797
- fn close(&self) -> Result<(), Error> {
798
- *self.inner.borrow_mut() = None;
799
- Ok(())
800
- }
801
-
802
- fn websocket(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
803
- let inner_borrow = this.inner.borrow();
804
- let inner = inner_borrow
805
- .as_ref()
806
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
807
-
808
- let server = inner.transport_server.clone().ok_or_else(|| {
809
- Error::new(
810
- ruby.exception_runtime_error(),
811
- "WebSocket transport server unavailable (no WebSocket handlers registered)",
812
- )
813
- })?;
814
-
815
- drop(inner_borrow);
816
-
817
- let timeout_duration = websocket_timeout();
818
- let ws = crate::call_without_gvl!(
819
- block_on_websocket_connect,
820
- args: (
821
- server, Arc<TestServer>,
822
- path, String,
823
- timeout_duration, Duration
824
- ),
825
- return_type: Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError>
826
- )
827
- .map_err(|err| match err {
828
- WebSocketConnectError::Timeout => Error::new(
829
- ruby.exception_runtime_error(),
830
- format!("WebSocket connect timed out after {}ms", timeout_duration.as_millis()),
831
- ),
832
- WebSocketConnectError::Other(message) => Error::new(
833
- ruby.exception_runtime_error(),
834
- format!("WebSocket connect failed: {}", message),
835
- ),
836
- })?;
837
-
838
- let ws_conn = testing::websocket::WebSocketTestConnection::new(ws);
839
- Ok(ruby.obj_wrap(ws_conn).as_value())
840
- }
841
-
842
- fn sse(ruby: &Ruby, this: &Self, path: String) -> Result<Value, Error> {
843
- let inner_borrow = this.inner.borrow();
844
- let inner = inner_borrow
845
- .as_ref()
846
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
847
-
848
- let runtime = crate::server::global_runtime(ruby)?;
849
- let server = inner.http_server.clone();
850
- let http_method = Method::GET;
851
- let request_config = RequestConfig {
852
- query: None,
853
- headers: HashMap::new(),
854
- cookies: HashMap::new(),
855
- body: None,
856
- };
857
- let response = crate::call_without_gvl!(
858
- testing::client::block_on_request,
859
- args: (
860
- runtime, &tokio::runtime::Runtime,
861
- server, Arc<TestServer>,
862
- http_method, Method,
863
- path, String,
864
- request_config, RequestConfig
865
- ),
866
- return_type: Result<testing::client::TestResponseData, testing::client::NativeRequestError>
867
- )
868
- .map_err(|err| Error::new(ruby.exception_runtime_error(), format!("SSE request failed: {}", err.0)))?;
869
-
870
- let body = response.body_text.unwrap_or_default().into_bytes();
871
- let snapshot = ResponseSnapshot {
872
- status: response.status,
873
- headers: response.headers,
874
- body,
875
- };
876
-
877
- testing::sse::sse_stream_from_response(ruby, &snapshot)
878
- }
879
-
880
- fn graphql(
881
- ruby: &Ruby,
882
- this: &Self,
883
- query: String,
884
- variables: Value,
885
- operation_name: Value,
886
- ) -> Result<Value, Error> {
887
- let (_status, response) = Self::execute_graphql_impl(ruby, this, query, variables, operation_name)?;
888
- Ok(response)
889
- }
890
-
891
- fn graphql_with_status(
892
- ruby: &Ruby,
893
- this: &Self,
894
- query: String,
895
- variables: Value,
896
- operation_name: Value,
897
- ) -> Result<Value, Error> {
898
- let (status, response) = Self::execute_graphql_impl(ruby, this, query, variables, operation_name)?;
899
-
900
- let array = ruby.ary_new_capa(2);
901
- array.push(ruby.integer_from_i64(status as i64))?;
902
- array.push(response)?;
903
- Ok(array.as_value())
904
- }
905
-
906
- fn execute_graphql_impl(
907
- ruby: &Ruby,
908
- this: &Self,
909
- query: String,
910
- variables: Value,
911
- operation_name: Value,
912
- ) -> Result<(u16, Value), Error> {
913
- let inner_borrow = this.inner.borrow();
914
- let inner = inner_borrow
915
- .as_ref()
916
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "TestClient not initialised"))?;
917
-
918
- let json_module = ruby
919
- .class_object()
920
- .const_get("JSON")
921
- .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
922
-
923
- let variables_json = if variables.is_nil() {
924
- None
925
- } else {
926
- Some(ruby_value_to_json(ruby, json_module, variables)?)
927
- };
928
-
929
- let operation_name_str = if operation_name.is_nil() {
930
- None
931
- } else {
932
- Some(String::try_convert(operation_name)?)
933
- };
934
-
935
- let runtime = crate::server::global_runtime(ruby)?;
936
- let server = inner.http_server.clone();
937
- let query_value = query.clone();
938
-
939
- let snapshot = crate::call_without_gvl!(
940
- testing::client::block_on_graphql,
941
- args: (
942
- runtime, &tokio::runtime::Runtime,
943
- server, Arc<TestServer>,
944
- query_value, String,
945
- variables_json, Option<JsonValue>,
946
- operation_name_str, Option<String>
947
- ),
948
- return_type: Result<ResponseSnapshot, testing::client::NativeRequestError>
949
- )
950
- .map_err(|err| {
951
- Error::new(
952
- ruby.exception_runtime_error(),
953
- format!("GraphQL request failed: {}", err.0),
954
- )
955
- })?;
956
-
957
- let status = snapshot.status;
958
- let response = response_snapshot_to_ruby(ruby, snapshot)?;
959
- Ok((status, response))
960
- }
961
- }
962
-
963
- fn websocket_timeout() -> Duration {
964
- const DEFAULT_TIMEOUT_MS: u64 = 30_000;
965
- let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
966
- .ok()
967
- .and_then(|value| value.parse::<u64>().ok())
968
- .unwrap_or(DEFAULT_TIMEOUT_MS);
969
- Duration::from_millis(timeout_ms)
970
- }
971
-
972
- #[derive(Debug)]
973
- enum WebSocketConnectError {
974
- Timeout,
975
- Other(String),
976
- }
977
-
978
- fn block_on_websocket_connect(
979
- server: Arc<TestServer>,
980
- path: String,
981
- timeout_duration: Duration,
982
- ) -> Result<crate::testing::websocket::WebSocketConnection, WebSocketConnectError> {
983
- let url = server
984
- .server_url(&path)
985
- .map_err(|err| WebSocketConnectError::Other(err.to_string()))?;
986
- let ws_url = to_ws_url(url)?;
987
-
988
- match crate::testing::websocket::WebSocketConnection::connect(ws_url, timeout_duration) {
989
- Ok(ws) => Ok(ws),
990
- Err(crate::testing::websocket::WebSocketIoError::Timeout) => Err(WebSocketConnectError::Timeout),
991
- Err(err) => Err(WebSocketConnectError::Other(format!("{:?}", err))),
992
- }
993
- }
994
-
995
- fn to_ws_url(mut url: Url) -> Result<Url, WebSocketConnectError> {
996
- let scheme = match url.scheme() {
997
- "https" => "wss",
998
- _ => "ws",
999
- };
1000
- url.set_scheme(scheme)
1001
- .map_err(|_| WebSocketConnectError::Other("Failed to set WebSocket scheme".to_string()))?;
1002
- Ok(url)
1003
- }
1004
-
1005
- impl RubyHandler {
1006
- fn new(route: &Route, handler_value: Value, json_module: Value) -> Result<Self, Error> {
1007
- let ruby = Ruby::get().map_err(|_| {
1008
- Error::new(
1009
- magnus::exception::runtime_error(),
1010
- "Ruby VM unavailable while creating handler",
1011
- )
1012
- })?;
1013
- let handler_value = crate::conversion::ensure_callable(&ruby, handler_value, &route.handler_name)?;
1014
-
1015
- Ok(Self {
1016
- inner: Arc::new(RubyHandlerInner {
1017
- handler_proc: Opaque::from(handler_value),
1018
- handler_name: route.handler_name.clone(),
1019
- json_module: Opaque::from(json_module),
1020
- response_validator: route.response_validator.clone(),
1021
- #[cfg(feature = "di")]
1022
- handler_dependencies: route.handler_dependencies.clone(),
1023
- }),
1024
- })
1025
- }
1026
-
1027
- /// Create a new RubyHandler for server mode
1028
- ///
1029
- /// This is used by run_server to create handlers from Ruby Procs
1030
- fn new_for_server(
1031
- ruby: &Ruby,
1032
- handler_value: Value,
1033
- handler_name: String,
1034
- json_module: Value,
1035
- route: &Route,
1036
- ) -> Result<Self, Error> {
1037
- let handler_value = crate::conversion::ensure_callable(ruby, handler_value, &handler_name)?;
1038
-
1039
- Ok(Self {
1040
- inner: Arc::new(RubyHandlerInner {
1041
- handler_proc: Opaque::from(handler_value),
1042
- handler_name,
1043
- json_module: Opaque::from(json_module),
1044
- response_validator: route.response_validator.clone(),
1045
- #[cfg(feature = "di")]
1046
- handler_dependencies: route.handler_dependencies.clone(),
1047
- }),
1048
- })
1049
- }
1050
-
1051
- /// Required by Ruby GC; invoked through the magnus mark hook.
1052
- #[allow(dead_code)]
1053
- fn mark(&self, marker: &Marker) {
1054
- if let Ok(ruby) = Ruby::get() {
1055
- let proc_val = self.inner.handler_proc.get_inner_with(&ruby);
1056
- marker.mark(proc_val);
1057
- }
1058
- }
1059
-
1060
- fn handle(&self, request_data: RequestData) -> HandlerResult {
1061
- with_gvl(|| {
1062
- let result = std::panic::catch_unwind(AssertUnwindSafe(|| self.handle_inner(request_data)));
1063
- match result {
1064
- Ok(res) => res,
1065
- Err(_) => Err((
1066
- StatusCode::INTERNAL_SERVER_ERROR,
1067
- "Unexpected panic while executing Ruby handler".to_string(),
1068
- )),
1069
- }
1070
- })
1071
- }
1072
-
1073
- fn handle_inner(&self, request_data: RequestData) -> HandlerResult {
1074
- // Clone Arc to avoid borrow checker issues with request_data later in the function.
1075
- // The Arc clone is cheap (increment ref count) and necessary here because we need to
1076
- // extract validated_params and also borrow request_data below.
1077
- let validated_params = request_data.validated_params.clone();
1078
-
1079
- let ruby = Ruby::get().map_err(|_| {
1080
- (
1081
- StatusCode::INTERNAL_SERVER_ERROR,
1082
- "Ruby VM unavailable while invoking handler".to_string(),
1083
- )
1084
- })?;
1085
-
1086
- #[cfg(feature = "di")]
1087
- let dependencies = request_data.dependencies.clone();
1088
-
1089
- // Use Arc::try_unwrap to eliminate the clone when validated_params Arc has unique ref.
1090
- // This is passed to NativeRequest::from_request_data which also tries to unwrap.
1091
- // The pattern handles both cases:
1092
- // - Most requests: Arc has unique ref → try_unwrap succeeds, no extra clone
1093
- // - Shared Arc (rare): try_unwrap fails → fallback to clone, safe and correct
1094
- let request_value = build_ruby_request(
1095
- &ruby,
1096
- request_data,
1097
- validated_params.map(|arc| Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone())),
1098
- )
1099
- .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
1100
-
1101
- let handler_proc = self.inner.handler_proc.get_inner_with(&ruby);
1102
-
1103
- #[cfg(feature = "di")]
1104
- let handler_result = {
1105
- if let Some(deps) = &dependencies {
1106
- let kwargs_hash = ruby.hash_new();
1107
-
1108
- for key in &self.inner.handler_dependencies {
1109
- if !deps.contains(key) {
1110
- return Err((
1111
- StatusCode::INTERNAL_SERVER_ERROR,
1112
- format!(
1113
- "Handler '{}' requires dependency '{}' which was not resolved",
1114
- self.inner.handler_name, key
1115
- ),
1116
- ));
1117
- }
1118
- }
1119
-
1120
- for key in &self.inner.handler_dependencies {
1121
- if let Some(value) = deps.get_arc(key) {
1122
- let ruby_val = if let Some(wrapper) = value.downcast_ref::<crate::di::RubyValueWrapper>() {
1123
- wrapper.get_value(&ruby)
1124
- } else if let Some(json) = value.downcast_ref::<serde_json::Value>() {
1125
- match crate::di::json_to_ruby(&ruby, json) {
1126
- Ok(val) => val,
1127
- Err(e) => {
1128
- return Err((
1129
- StatusCode::INTERNAL_SERVER_ERROR,
1130
- format!("Failed to convert dependency '{}' to Ruby: {}", key, e),
1131
- ));
1132
- }
1133
- }
1134
- } else {
1135
- return Err((
1136
- StatusCode::INTERNAL_SERVER_ERROR,
1137
- format!(
1138
- "Unknown dependency type for '{}': expected RubyValueWrapper or JSON",
1139
- key
1140
- ),
1141
- ));
1142
- };
1143
-
1144
- let key_sym = ruby.to_symbol(key);
1145
- if let Err(e) = kwargs_hash.aset(key_sym, ruby_val) {
1146
- return Err((
1147
- StatusCode::INTERNAL_SERVER_ERROR,
1148
- format!("Failed to add dependency '{}': {}", key, e),
1149
- ));
1150
- }
1151
- }
1152
- }
1153
-
1154
- call_handler_proc_with_kwargs(&ruby, handler_proc, request_value, kwargs_hash.as_value())
1155
- } else {
1156
- call_handler_proc(&ruby, handler_proc, request_value)
1157
- }
1158
- };
1159
-
1160
- #[cfg(not(feature = "di"))]
1161
- let handler_result = call_handler_proc(&ruby, handler_proc, request_value);
1162
-
1163
- let response_value = match handler_result {
1164
- Ok(value) => value,
1165
- Err(err) => return Err(problem_from_ruby_error(&ruby, &self.inner, err)),
1166
- };
1167
-
1168
- let handler_result = interpret_handler_response(&ruby, &self.inner, response_value).map_err(|err| {
1169
- (
1170
- StatusCode::INTERNAL_SERVER_ERROR,
1171
- format!(
1172
- "Failed to interpret response from '{}': {}",
1173
- self.inner.handler_name, err
1174
- ),
1175
- )
1176
- })?;
1177
-
1178
- let payload = match handler_result {
1179
- RubyHandlerResult::Native(native) => {
1180
- if let (Some(validator), Some(body)) = (&self.inner.response_validator, native.body_json.as_ref())
1181
- && let Err(errors) = validator.validate(body)
1182
- {
1183
- let problem = ProblemDetails::from_validation_error(&errors);
1184
- return Err((StatusCode::INTERNAL_SERVER_ERROR, problem_to_json(&problem)));
1185
- }
1186
-
1187
- return Ok(native.response.into_response());
1188
- }
1189
- RubyHandlerResult::Streaming(streaming) => {
1190
- let response = streaming.into_response().map_err(|err| {
1191
- (
1192
- StatusCode::INTERNAL_SERVER_ERROR,
1193
- format!("Failed to build streaming response: {}", err),
1194
- )
1195
- })?;
1196
- return Ok(response.into_response());
1197
- }
1198
- RubyHandlerResult::Payload(payload) => payload,
1199
- };
1200
-
1201
- if let (Some(validator), Some(body)) = (&self.inner.response_validator, payload.body.as_ref())
1202
- && let Err(errors) = validator.validate(body)
1203
- {
1204
- let problem = ProblemDetails::from_validation_error(&errors);
1205
- return Err((StatusCode::INTERNAL_SERVER_ERROR, problem_to_json(&problem)));
1206
- }
1207
-
1208
- let HandlerResponsePayload {
1209
- status,
1210
- headers,
1211
- body,
1212
- raw_body,
1213
- } = payload;
1214
-
1215
- let mut response_builder = axum::http::Response::builder().status(status);
1216
- let mut has_content_type = false;
1217
-
1218
- for (name, value) in headers.iter() {
1219
- if name.eq_ignore_ascii_case("content-type") {
1220
- has_content_type = true;
1221
- }
1222
- let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
1223
- (
1224
- StatusCode::INTERNAL_SERVER_ERROR,
1225
- format!("Invalid header name '{name}': {err}"),
1226
- )
1227
- })?;
1228
- let header_value = HeaderValue::from_str(value).map_err(|err| {
1229
- (
1230
- StatusCode::INTERNAL_SERVER_ERROR,
1231
- format!("Invalid header value for '{name}': {err}"),
1232
- )
1233
- })?;
1234
-
1235
- response_builder = response_builder.header(header_name, header_value);
1236
- }
1237
-
1238
- if !has_content_type && body.is_some() {
1239
- response_builder = response_builder.header(
1240
- HeaderName::from_static("content-type"),
1241
- HeaderValue::from_static("application/json"),
1242
- );
1243
- }
1244
-
1245
- let body_bytes = if let Some(raw) = raw_body {
1246
- raw
1247
- } else if let Some(json_value) = body {
1248
- serde_json::to_vec(&json_value).map_err(|err| {
1249
- (
1250
- StatusCode::INTERNAL_SERVER_ERROR,
1251
- format!("Failed to serialise response body: {err}"),
1252
- )
1253
- })?
1254
- } else {
1255
- Vec::new()
1256
- };
1257
-
1258
- response_builder.body(Body::from(body_bytes)).map_err(|err| {
1259
- (
1260
- StatusCode::INTERNAL_SERVER_ERROR,
1261
- format!("Failed to build response: {err}"),
1262
- )
1263
- })
1264
- }
1265
- }
1266
-
1267
- impl Handler for RubyHandler {
1268
- fn call(
1269
- &self,
1270
- _req: axum::http::Request<Body>,
1271
- request_data: RequestData,
1272
- ) -> Pin<Box<dyn std::future::Future<Output = HandlerResult> + Send + '_>> {
1273
- let handler = self.clone();
1274
- Box::pin(async move { handler.handle(request_data) })
1275
- }
1276
- }
1277
-
1278
- fn call_handler_proc(ruby: &Ruby, handler_proc: Value, request_value: Value) -> Result<Value, Error> {
1279
- let arity: i64 = handler_proc.funcall("arity", ())?;
1280
- let call_arity = normalize_proc_arity(arity);
1281
-
1282
- if call_arity == 0 {
1283
- return handler_proc.funcall("call", ());
1284
- }
1285
-
1286
- if call_arity == 1 {
1287
- return handler_proc.funcall("call", (request_value,));
1288
- }
1289
-
1290
- let (params_value, query_value, body_value) = request_parts_from_value(ruby, request_value)?;
1291
- if call_arity == 2 {
1292
- return handler_proc.funcall("call", (params_value, query_value));
1293
- }
1294
-
1295
- handler_proc.funcall("call", (params_value, query_value, body_value))
1296
- }
1297
-
1298
- fn call_handler_proc_with_kwargs(
1299
- ruby: &Ruby,
1300
- handler_proc: Value,
1301
- request_value: Value,
1302
- kwargs_hash: Value,
1303
- ) -> Result<Value, Error> {
1304
- let arity: i64 = handler_proc.funcall("arity", ())?;
1305
- let call_arity = normalize_proc_arity(arity);
1306
- let (params_value, query_value, body_value) = request_parts_from_value(ruby, request_value)?;
1307
-
1308
- let wrapper_code = ruby.eval::<Value>(
1309
- r"
1310
- lambda do |proc, arity, request, params, query, body, kwargs|
1311
- kwargs = {} if kwargs.nil?
1312
- case arity
1313
- when 0
1314
- kwargs.empty? ? proc.call : proc.call(**kwargs)
1315
- when 1
1316
- kwargs.empty? ? proc.call(request) : proc.call(request, **kwargs)
1317
- when 2
1318
- kwargs.empty? ? proc.call(params, query) : proc.call(params, query, **kwargs)
1319
- else
1320
- kwargs.empty? ? proc.call(params, query, body) : proc.call(params, query, body, **kwargs)
1321
- end
1322
- end
1323
- ",
1324
- )?;
1325
-
1326
- wrapper_code.funcall(
1327
- "call",
1328
- (
1329
- handler_proc,
1330
- ruby.integer_from_i64(call_arity),
1331
- request_value,
1332
- params_value,
1333
- query_value,
1334
- body_value,
1335
- kwargs_hash,
1336
- ),
1337
- )
1338
- }
1339
-
1340
- fn request_parts_from_value(ruby: &Ruby, request_value: Value) -> Result<(Value, Value, Value), Error> {
1341
- if let Ok(request) = <&NativeRequest>::try_convert(request_value) {
1342
- let params_value = NativeRequest::path_params(ruby, request)?;
1343
- let query_value = NativeRequest::query(ruby, request)?;
1344
- let body_value = NativeRequest::body(ruby, request)?;
1345
- return Ok((params_value, query_value, body_value));
1346
- }
1347
-
1348
- if let Ok(hash) = RHash::try_convert(request_value) {
1349
- let params_value = hash.get("path_params").unwrap_or_else(|| ruby.qnil().as_value());
1350
- let query_value = hash.get("query").unwrap_or_else(|| ruby.qnil().as_value());
1351
- let body_value = hash.get("body").unwrap_or_else(|| ruby.qnil().as_value());
1352
- return Ok((params_value, query_value, body_value));
1353
- }
1354
-
1355
- Ok((ruby.qnil().as_value(), ruby.qnil().as_value(), ruby.qnil().as_value()))
1356
- }
1357
-
1358
- fn normalize_proc_arity(arity: i64) -> i64 {
1359
- if arity < 0 { 3 } else { arity }
1360
- }
1361
-
1362
- fn problem_from_ruby_error(ruby: &Ruby, handler: &RubyHandlerInner, err: Error) -> (StatusCode, String) {
1363
- let mut status = StatusCode::INTERNAL_SERVER_ERROR;
1364
- let mut detail = ruby_error_message(ruby, &err);
1365
- let mut code_value: Option<JsonValue> = None;
1366
- let mut details_value: Option<JsonValue> = None;
1367
- let mut extensions: HashMap<String, JsonValue> = HashMap::new();
1368
-
1369
- if err.is_kind_of(ruby.exception_arg_error()) {
1370
- status = StatusCode::BAD_REQUEST;
1371
- }
1372
-
1373
- if let Some(exception) = err.value() {
1374
- if matches!(exception.respond_to("status", false), Ok(true)) {
1375
- if let Ok(code) = exception.funcall::<_, _, i64>("status", ()) {
1376
- status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1377
- }
1378
- } else if matches!(exception.respond_to("status_code", false), Ok(true))
1379
- && let Ok(code) = exception.funcall::<_, _, i64>("status_code", ())
1380
- {
1381
- status = StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1382
- }
1383
-
1384
- let json_module = handler.json_module.get_inner_with(ruby);
1385
- if matches!(exception.respond_to("code", false), Ok(true))
1386
- && let Ok(value) = exception.funcall::<_, _, Value>("code", ())
1387
- && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
1388
- {
1389
- code_value = Some(json_value);
1390
- }
1391
-
1392
- if matches!(exception.respond_to("details", false), Ok(true))
1393
- && let Ok(value) = exception.funcall::<_, _, Value>("details", ())
1394
- && let Ok(json_value) = ruby_value_to_json(ruby, json_module, value)
1395
- {
1396
- details_value = Some(json_value);
1397
- }
1398
- }
1399
-
1400
- detail = sanitize_error_detail(&detail);
1401
-
1402
- let code_value = code_value.unwrap_or_else(|| JsonValue::String(error_code_for_status(status).to_string()));
1403
- let details_value = details_value.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
1404
- extensions.insert("error".to_string(), JsonValue::String(detail.clone()));
1405
- extensions.insert("code".to_string(), code_value);
1406
- extensions.insert("details".to_string(), details_value);
1407
-
1408
- let mut problem = problem_for_status(status, detail);
1409
- for (key, value) in extensions {
1410
- problem = problem.with_extension(key, value);
1411
- }
1412
-
1413
- ErrorResponseBuilder::problem_details_response(&problem)
1414
- }
1415
-
1416
- fn ruby_error_message(_ruby: &Ruby, err: &Error) -> String {
1417
- if let Some(exception) = err.value()
1418
- && matches!(exception.respond_to("message", false), Ok(true))
1419
- && let Ok(message) = exception.funcall::<_, _, String>("message", ())
1420
- {
1421
- return message;
1422
- }
1423
- err.to_string()
1424
- }
1425
-
1426
- fn problem_for_status(status: StatusCode, detail: String) -> ProblemDetails {
1427
- match status {
1428
- StatusCode::BAD_REQUEST => ProblemDetails::bad_request(detail),
1429
- StatusCode::UNAUTHORIZED => {
1430
- ProblemDetails::new("https://spikard.dev/errors/unauthorized", "Unauthorized", status).with_detail(detail)
1431
- }
1432
- StatusCode::FORBIDDEN => {
1433
- ProblemDetails::new("https://spikard.dev/errors/forbidden", "Forbidden", status).with_detail(detail)
1434
- }
1435
- StatusCode::NOT_FOUND => ProblemDetails::not_found(detail),
1436
- StatusCode::UNPROCESSABLE_ENTITY => ProblemDetails::new(
1437
- ProblemDetails::TYPE_VALIDATION_ERROR,
1438
- "Request Validation Failed",
1439
- status,
1440
- )
1441
- .with_detail(detail),
1442
- _ => ProblemDetails::internal_server_error(detail),
1443
- }
1444
- }
1445
-
1446
- fn error_code_for_status(status: StatusCode) -> &'static str {
1447
- match status {
1448
- StatusCode::BAD_REQUEST => "bad_request",
1449
- StatusCode::UNAUTHORIZED => "unauthorized",
1450
- StatusCode::FORBIDDEN => "forbidden",
1451
- StatusCode::NOT_FOUND => "not_found",
1452
- StatusCode::METHOD_NOT_ALLOWED => "method_not_allowed",
1453
- StatusCode::REQUEST_TIMEOUT => "request_timeout",
1454
- StatusCode::CONFLICT => "conflict",
1455
- StatusCode::SERVICE_UNAVAILABLE => "service_unavailable",
1456
- StatusCode::UNPROCESSABLE_ENTITY => "unprocessable_entity",
1457
- _ => "internal_error",
1458
- }
1459
- }
1460
-
1461
- fn sanitize_error_detail(detail: &str) -> String {
1462
- let mut tokens = Vec::new();
1463
- let mut redact_next = false;
1464
-
1465
- for token in detail.split_whitespace() {
1466
- let lower = token.to_lowercase();
1467
- if token.starts_with('/') || token.contains(".rb:") {
1468
- tokens.push("[redacted]".to_string());
1469
- redact_next = false;
1470
- continue;
1471
- }
1472
-
1473
- if lower.starts_with("password=") {
1474
- tokens.push("password=[redacted]".to_string());
1475
- redact_next = false;
1476
- continue;
1477
- }
1478
-
1479
- if lower.starts_with("host=") {
1480
- tokens.push("host=[redacted]".to_string());
1481
- redact_next = false;
1482
- continue;
1483
- }
1484
-
1485
- if lower.starts_with("token=") || lower.starts_with("secret=") {
1486
- tokens.push("[redacted]".to_string());
1487
- redact_next = false;
1488
- continue;
1489
- }
1490
-
1491
- if redact_next {
1492
- tokens.push("[redacted]".to_string());
1493
- redact_next = false;
1494
- continue;
1495
- }
1496
-
1497
- if token.eq_ignore_ascii_case("in") {
1498
- tokens.push(token.to_string());
1499
- redact_next = true;
1500
- continue;
1501
- }
1502
-
1503
- tokens.push(token.to_string());
1504
- }
1505
-
1506
- let mut sanitized = tokens.join(" ");
1507
- sanitized = sanitized.replace("SELECT *", "[redacted]");
1508
- sanitized = sanitized.replace("select *", "[redacted]");
1509
- sanitized = sanitized.replace("FROM users", "[redacted]");
1510
- sanitized = sanitized.replace("from users", "[redacted]");
1511
- sanitized
1512
- }
1513
-
1514
- // These functions are now in testing::client module - use call_without_gvl! macro to call them
1515
-
1516
- fn response_snapshot_to_ruby(ruby: &Ruby, snapshot: ResponseSnapshot) -> Result<Value, Error> {
1517
- let hash = ruby.hash_new();
1518
-
1519
- hash.aset(
1520
- ruby.intern("status_code"),
1521
- ruby.integer_from_i64(snapshot.status as i64),
1522
- )?;
1523
-
1524
- let headers_hash = ruby.hash_new();
1525
- for (key, value) in snapshot.headers {
1526
- headers_hash.aset(ruby.str_new(&key), ruby.str_new(&value))?;
1527
- }
1528
- hash.aset(ruby.intern("headers"), headers_hash)?;
1529
-
1530
- let body_value = ruby.str_new(&String::from_utf8_lossy(&snapshot.body));
1531
- hash.aset(ruby.intern("body"), body_value)?;
1532
- hash.aset(ruby.intern("body_text"), body_value)?;
1533
-
1534
- Ok(hash.as_value())
1535
- }
1536
-
1537
- fn parse_request_config(ruby: &Ruby, options: Value) -> Result<RequestConfig, Error> {
1538
- if options.is_nil() {
1539
- return Ok(RequestConfig {
1540
- query: None,
1541
- headers: HashMap::new(),
1542
- cookies: HashMap::new(),
1543
- body: None,
1544
- });
1545
- }
1546
-
1547
- let hash = RHash::from_value(options)
1548
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "request options must be a Hash"))?;
1549
-
1550
- let json_module = ruby
1551
- .class_object()
1552
- .const_get("JSON")
1553
- .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
1554
-
1555
- let query = if let Some(value) = get_kw(ruby, hash, "query") {
1556
- if value.is_nil() {
1557
- None
1558
- } else {
1559
- Some(ruby_value_to_json(ruby, json_module, value)?)
1560
- }
1561
- } else {
1562
- None
1563
- };
1564
-
1565
- let headers = if let Some(value) = get_kw(ruby, hash, "headers") {
1566
- if value.is_nil() {
1567
- HashMap::new()
1568
- } else {
1569
- let hash = RHash::try_convert(value)?;
1570
- hash.to_hash_map::<String, String>()?
1571
- }
1572
- } else {
1573
- HashMap::new()
1574
- };
1575
-
1576
- let cookies = if let Some(value) = get_kw(ruby, hash, "cookies") {
1577
- if value.is_nil() {
1578
- HashMap::new()
1579
- } else {
1580
- let hash = RHash::try_convert(value)?;
1581
- hash.to_hash_map::<String, String>()?
1582
- }
1583
- } else {
1584
- HashMap::new()
1585
- };
1586
-
1587
- let files_opt = get_kw(ruby, hash, "files");
1588
- let has_files = files_opt.as_ref().is_some_and(|f| !f.is_nil());
1589
-
1590
- let body = if has_files {
1591
- let files_value = files_opt.ok_or_else(|| {
1592
- Error::new(
1593
- ruby.exception_runtime_error(),
1594
- "Files option should be Some if has_files is true",
1595
- )
1596
- })?;
1597
- let files = extract_files(ruby, files_value)?;
1598
-
1599
- let mut form_data = Vec::new();
1600
- if let Some(data_value) = get_kw(ruby, hash, "data")
1601
- && !data_value.is_nil()
1602
- {
1603
- let data_hash = RHash::try_convert(data_value)?;
1604
-
1605
- let keys_array: RArray = data_hash.funcall("keys", ())?;
1606
-
1607
- for i in 0..keys_array.len() {
1608
- let key_val = keys_array.entry::<Value>(i as isize)?;
1609
- let field_name = String::try_convert(key_val)?;
1610
- let value = data_hash
1611
- .get(key_val)
1612
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "Failed to get hash value"))?;
1613
-
1614
- if let Some(array) = RArray::from_value(value) {
1615
- for j in 0..array.len() {
1616
- let item = array.entry::<Value>(j as isize)?;
1617
- let item_str = String::try_convert(item)?;
1618
- form_data.push((field_name.clone(), item_str));
1619
- }
1620
- } else {
1621
- let value_str = String::try_convert(value)?;
1622
- form_data.push((field_name, value_str));
1623
- }
1624
- }
1625
- }
1626
-
1627
- Some(RequestBody::Multipart { form_data, files })
1628
- } else if let Some(value) = get_kw(ruby, hash, "json") {
1629
- if value.is_nil() {
1630
- None
1631
- } else {
1632
- Some(RequestBody::Json(ruby_value_to_json(ruby, json_module, value)?))
1633
- }
1634
- } else if let Some(value) = get_kw(ruby, hash, "data") {
1635
- if value.is_nil() {
1636
- None
1637
- } else {
1638
- Some(RequestBody::Form(ruby_value_to_json(ruby, json_module, value)?))
1639
- }
1640
- } else if let Some(value) = get_kw(ruby, hash, "raw_body") {
1641
- if value.is_nil() {
1642
- None
1643
- } else {
1644
- Some(RequestBody::Raw(String::try_convert(value)?))
1645
- }
1646
- } else {
1647
- None
1648
- };
1649
-
1650
- Ok(RequestConfig {
1651
- query,
1652
- headers,
1653
- cookies,
1654
- body,
1655
- })
1656
- }
1657
-
1658
- fn build_ruby_request(
1659
- ruby: &Ruby,
1660
- request_data: RequestData,
1661
- validated_params: Option<JsonValue>,
1662
- ) -> Result<Value, Error> {
1663
- let native_request = NativeRequest::from_request_data(request_data, validated_params, None);
1664
-
1665
- Ok(ruby.obj_wrap(native_request).as_value())
1666
- }
1667
-
1668
- fn interpret_handler_response(
1669
- ruby: &Ruby,
1670
- handler: &RubyHandlerInner,
1671
- value: Value,
1672
- ) -> Result<RubyHandlerResult, Error> {
1673
- let native_method = ruby.intern("to_native_response");
1674
- if value.respond_to(native_method, false)? {
1675
- let native_value: Value = value.funcall("to_native_response", ())?;
1676
- if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(native_value) {
1677
- let (response, body_json) = native_resp.extract_parts()?;
1678
- return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
1679
- }
1680
- } else if let Ok(native_resp) = <&NativeBuiltResponse>::try_convert(value) {
1681
- let (response, body_json) = native_resp.extract_parts()?;
1682
- return Ok(RubyHandlerResult::Native(NativeResponseParts { response, body_json }));
1683
- }
1684
-
1685
- if value.is_nil() {
1686
- return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1687
- status: 200,
1688
- headers: HashMap::new(),
1689
- body: None,
1690
- raw_body: None,
1691
- }));
1692
- }
1693
-
1694
- if is_streaming_response(ruby, value)? {
1695
- let stream_value: Value = value.funcall("stream", ())?;
1696
- let status: i64 = value.funcall("status_code", ())?;
1697
- let headers_value: Value = value.funcall("headers", ())?;
1698
-
1699
- let status_u16 = u16::try_from(status).map_err(|_| {
1700
- Error::new(
1701
- ruby.exception_arg_error(),
1702
- "StreamingResponse status_code must be between 0 and 65535",
1703
- )
1704
- })?;
1705
-
1706
- let headers = value_to_string_map(ruby, headers_value)?;
1707
-
1708
- return Ok(RubyHandlerResult::Streaming(StreamingResponsePayload {
1709
- enumerator: Arc::new(Opaque::from(stream_value)),
1710
- status: status_u16,
1711
- headers,
1712
- }));
1713
- }
1714
-
1715
- let status_symbol = ruby.intern("status_code");
1716
- if value.respond_to(status_symbol, false)? {
1717
- let status: i64 = value.funcall("status_code", ())?;
1718
- let status_u16 = u16::try_from(status)
1719
- .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1720
-
1721
- let headers_value: Value = value.funcall("headers", ())?;
1722
- let headers = if headers_value.is_nil() {
1723
- HashMap::new()
1724
- } else {
1725
- let hash = RHash::try_convert(headers_value)?;
1726
- hash.to_hash_map::<String, String>()?
1727
- };
1728
-
1729
- let content_value: Value = value.funcall("content", ())?;
1730
- let mut raw_body = None;
1731
- let body = if content_value.is_nil() {
1732
- None
1733
- } else if let Ok(str_value) = RString::try_convert(content_value) {
1734
- let slice = unsafe { str_value.as_slice() };
1735
- raw_body = Some(slice.to_vec());
1736
- None
1737
- } else {
1738
- Some(ruby_value_to_json(
1739
- ruby,
1740
- handler.json_module.get_inner_with(ruby),
1741
- content_value,
1742
- )?)
1743
- };
1744
-
1745
- return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1746
- status: status_u16,
1747
- headers,
1748
- body,
1749
- raw_body,
1750
- }));
1751
- }
1752
-
1753
- if let Ok(str_value) = RString::try_convert(value) {
1754
- let slice = unsafe { str_value.as_slice() };
1755
- return Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1756
- status: 200,
1757
- headers: HashMap::new(),
1758
- body: None,
1759
- raw_body: Some(slice.to_vec()),
1760
- }));
1761
- }
1762
-
1763
- let body_json = ruby_value_to_json(ruby, handler.json_module.get_inner_with(ruby), value)?;
1764
-
1765
- Ok(RubyHandlerResult::Payload(HandlerResponsePayload {
1766
- status: 200,
1767
- headers: HashMap::new(),
1768
- body: Some(body_json),
1769
- raw_body: None,
1770
- }))
1771
- }
1772
-
1773
- fn value_to_string_map(ruby: &Ruby, value: Value) -> Result<HashMap<String, String>, Error> {
1774
- if value.is_nil() {
1775
- return Ok(HashMap::new());
1776
- }
1777
- let hash = RHash::try_convert(value)?;
1778
- hash.to_hash_map::<String, String>().map_err(|err| {
1779
- Error::new(
1780
- ruby.exception_arg_error(),
1781
- format!("Expected headers hash of strings: {}", err),
1782
- )
1783
- })
1784
- }
1785
-
1786
- #[allow(dead_code)]
1787
- fn header_pairs_from_map(headers: HashMap<String, String>) -> Result<Vec<(HeaderName, HeaderValue)>, Error> {
1788
- let ruby = Ruby::get().map_err(|err| Error::new(magnus::exception::runtime_error(), err.to_string()))?;
1789
- headers
1790
- .into_iter()
1791
- .map(|(name, value)| {
1792
- let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
1793
- Error::new(
1794
- ruby.exception_arg_error(),
1795
- format!("Invalid header name '{name}': {err}"),
1796
- )
1797
- })?;
1798
- let header_value = HeaderValue::from_str(&value).map_err(|err| {
1799
- Error::new(
1800
- ruby.exception_arg_error(),
1801
- format!("Invalid header value for '{name}': {err}"),
1802
- )
1803
- })?;
1804
- Ok((header_name, header_value))
1805
- })
1806
- .collect()
1807
- }
1808
-
1809
- fn is_streaming_response(ruby: &Ruby, value: Value) -> Result<bool, Error> {
1810
- let stream_sym = ruby.intern("stream");
1811
- let status_sym = ruby.intern("status_code");
1812
- Ok(value.respond_to(stream_sym, false)? && value.respond_to(status_sym, false)?)
1813
- }
1814
-
1815
- fn response_to_ruby(ruby: &Ruby, response: testing::client::TestResponseData) -> Result<Value, Error> {
1816
- let hash = ruby.hash_new();
1817
-
1818
- hash.aset(
1819
- ruby.intern("status_code"),
1820
- ruby.integer_from_i64(response.status as i64),
1821
- )?;
1822
-
1823
- let headers_hash = ruby.hash_new();
1824
- for (key, value) in response.headers {
1825
- headers_hash.aset(ruby.str_new(&key), ruby.str_new(&value))?;
1826
- }
1827
- hash.aset(ruby.intern("headers"), headers_hash)?;
1828
-
1829
- if let Some(body) = response.body_text {
1830
- let body_value = ruby.str_new(&body);
1831
- hash.aset(ruby.intern("body"), body_value)?;
1832
- hash.aset(ruby.intern("body_text"), body_value)?;
1833
- } else {
1834
- hash.aset(ruby.intern("body"), ruby.qnil())?;
1835
- hash.aset(ruby.intern("body_text"), ruby.qnil())?;
1836
- }
1837
-
1838
- Ok(hash.as_value())
1839
- }
1840
-
1841
- fn get_kw(ruby: &Ruby, hash: RHash, name: &str) -> Option<Value> {
1842
- let sym = ruby.intern(name);
1843
- hash.get(sym).or_else(|| hash.get(name))
1844
- }
1845
-
1846
- fn fetch_handler(ruby: &Ruby, handlers: &RHash, name: &str) -> Result<Value, Error> {
1847
- crate::conversion::fetch_handler(ruby, handlers, name)
1848
- }
1849
-
1850
- pub(crate) fn validate_route_metadata(ruby: &Ruby, meta: &RouteMetadata) -> Result<(), Error> {
1851
- // Validate method field is a valid HTTP method (must be uppercase)
1852
- match meta.method.as_str() {
1853
- "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE" => {}
1854
- _ => {
1855
- return Err(Error::new(
1856
- ruby.exception_arg_error(),
1857
- format!(
1858
- "Invalid routes JSON: method must be a valid HTTP method in uppercase (got '{}')",
1859
- meta.method
1860
- ),
1861
- ));
1862
- }
1863
- }
1864
-
1865
- if meta.path.trim().is_empty() || !meta.path.starts_with('/') {
1866
- return Err(Error::new(
1867
- ruby.exception_arg_error(),
1868
- format!("Invalid routes JSON: path must start with '/' (got '{}')", meta.path),
1869
- ));
1870
- }
1871
-
1872
- Ok(())
1873
- }
1874
-
1875
- /// GC mark hook so Ruby keeps handler closures alive.
1876
- #[allow(dead_code)]
1877
- fn mark(client: &NativeTestClient, marker: &Marker) {
1878
- let inner_ref = client.inner.borrow();
1879
- if let Some(inner) = inner_ref.as_ref() {
1880
- for handler in &inner._handlers {
1881
- handler.mark(marker);
1882
- }
1883
- }
1884
- }
1885
-
1886
- /// Return the Spikard version.
1887
- fn version() -> String {
1888
- env!("CARGO_PKG_VERSION").to_string()
1889
- }
1890
-
1891
- /// Build a native response from content, status code, and headers.
1892
- ///
1893
- /// Called by `Spikard::Response` to construct native response objects.
1894
- /// The content can be a String (raw body), Hash/Array (JSON), or nil.
1895
- fn build_response(ruby: &Ruby, content: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
1896
- let status_u16 = u16::try_from(status_code)
1897
- .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1898
-
1899
- let header_map = if headers.is_nil() {
1900
- HashMap::new()
1901
- } else {
1902
- let hash = RHash::try_convert(headers)?;
1903
- hash.to_hash_map::<String, String>()?
1904
- };
1905
-
1906
- let (body_json, raw_body_opt) = if content.is_nil() {
1907
- (None, None)
1908
- } else if let Ok(str_value) = RString::try_convert(content) {
1909
- let slice = unsafe { str_value.as_slice() };
1910
- (None, Some(slice.to_vec()))
1911
- } else {
1912
- let json_module = ruby
1913
- .class_object()
1914
- .const_get("JSON")
1915
- .map_err(|_| Error::new(ruby.exception_runtime_error(), "JSON module not available"))?;
1916
- let json_value = ruby_value_to_json(ruby, json_module, content)?;
1917
- (Some(json_value), None)
1918
- };
1919
-
1920
- let status = StatusCode::from_u16(status_u16).map_err(|err| {
1921
- Error::new(
1922
- ruby.exception_arg_error(),
1923
- format!("Invalid status code {}: {}", status_u16, err),
1924
- )
1925
- })?;
1926
-
1927
- let mut response_builder = axum::http::Response::builder().status(status);
1928
-
1929
- for (name, value) in &header_map {
1930
- let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|err| {
1931
- Error::new(
1932
- ruby.exception_arg_error(),
1933
- format!("Invalid header name '{}': {}", name, err),
1934
- )
1935
- })?;
1936
- let header_value = HeaderValue::from_str(value).map_err(|err| {
1937
- Error::new(
1938
- ruby.exception_arg_error(),
1939
- format!("Invalid header value for '{}': {}", name, err),
1940
- )
1941
- })?;
1942
- response_builder = response_builder.header(header_name, header_value);
1943
- }
1944
-
1945
- let body_bytes = if let Some(raw) = raw_body_opt {
1946
- raw
1947
- } else if let Some(json_value) = body_json.as_ref() {
1948
- serde_json::to_vec(&json_value).map_err(|err| {
1949
- Error::new(
1950
- ruby.exception_runtime_error(),
1951
- format!("Failed to serialise response body: {}", err),
1952
- )
1953
- })?
1954
- } else {
1955
- Vec::new()
1956
- };
1957
-
1958
- let axum_response = response_builder.body(Body::from(body_bytes)).map_err(|err| {
1959
- Error::new(
1960
- ruby.exception_runtime_error(),
1961
- format!("Failed to build response: {}", err),
1962
- )
1963
- })?;
1964
-
1965
- let handler_response = HandlerResponse::Response(axum_response);
1966
- let native_response = NativeBuiltResponse::new(handler_response, body_json.clone(), Vec::new());
1967
- Ok(ruby.obj_wrap(native_response).as_value())
1968
- }
1969
-
1970
- /// Build a native streaming response from stream, status code, and headers.
1971
- ///
1972
- /// Called by `Spikard::StreamingResponse` to construct native response objects.
1973
- /// The stream must be an enumerator that responds to #next.
1974
- fn build_streaming_response(ruby: &Ruby, stream: Value, status_code: i64, headers: Value) -> Result<Value, Error> {
1975
- let status_u16 = u16::try_from(status_code)
1976
- .map_err(|_| Error::new(ruby.exception_arg_error(), "status_code must be between 0 and 65535"))?;
1977
-
1978
- let header_map = if headers.is_nil() {
1979
- HashMap::new()
1980
- } else {
1981
- let hash = RHash::try_convert(headers)?;
1982
- hash.to_hash_map::<String, String>()?
1983
- };
1984
-
1985
- let next_method = ruby.intern("next");
1986
- if !stream.respond_to(next_method, false)? {
1987
- return Err(Error::new(ruby.exception_arg_error(), "stream must respond to #next"));
1988
- }
1989
-
1990
- let streaming_payload = StreamingResponsePayload {
1991
- enumerator: Arc::new(Opaque::from(stream)),
1992
- status: status_u16,
1993
- headers: header_map,
1994
- };
1995
-
1996
- let response = streaming_payload.into_response()?;
1997
- let native_response = NativeBuiltResponse::new(response, None, vec![Opaque::from(stream)]);
1998
- Ok(ruby.obj_wrap(native_response).as_value())
1999
- }
2000
-
2001
- #[magnus::init]
2002
- pub fn init(ruby: &Ruby) -> Result<(), Error> {
2003
- let spikard = ruby.define_module("Spikard")?;
2004
- spikard.define_singleton_method("version", function!(version, 0))?;
2005
- let native = match spikard.const_get("Native") {
2006
- Ok(module) => module,
2007
- Err(_) => spikard.define_module("Native")?,
2008
- };
2009
-
2010
- native.define_singleton_method("run_server", function!(run_server, 7))?;
2011
- native.define_singleton_method("normalize_route_metadata", function!(normalize_route_metadata, 1))?;
2012
- native.define_singleton_method("background_run", function!(background::background_run, 1))?;
2013
- native.define_singleton_method("build_route_metadata", function!(build_route_metadata, 12))?;
2014
- native.define_singleton_method("build_response", function!(build_response, 3))?;
2015
- native.define_singleton_method("build_streaming_response", function!(build_streaming_response, 3))?;
2016
-
2017
- let class = native.define_class("TestClient", ruby.class_object())?;
2018
- class.define_alloc_func::<NativeTestClient>();
2019
- class.define_method("initialize", method!(NativeTestClient::initialize, 6))?;
2020
- class.define_method("request", method!(NativeTestClient::request, 3))?;
2021
- class.define_method("websocket", method!(NativeTestClient::websocket, 1))?;
2022
- class.define_method("sse", method!(NativeTestClient::sse, 1))?;
2023
- class.define_method("graphql", method!(NativeTestClient::graphql, 3))?;
2024
- class.define_method("graphql_with_status", method!(NativeTestClient::graphql_with_status, 3))?;
2025
- class.define_method("close", method!(NativeTestClient::close, 0))?;
2026
-
2027
- let built_response_class = native.define_class("BuiltResponse", ruby.class_object())?;
2028
- built_response_class.define_method("status_code", method!(NativeBuiltResponse::status_code, 0))?;
2029
- built_response_class.define_method("headers", method!(NativeBuiltResponse::headers, 0))?;
2030
-
2031
- let request_class = native.define_class("Request", ruby.class_object())?;
2032
- request_class.define_method("method", method!(NativeRequest::method, 0))?;
2033
- request_class.define_method("path", method!(NativeRequest::path, 0))?;
2034
- request_class.define_method("path_params", method!(NativeRequest::path_params, 0))?;
2035
- request_class.define_method("query", method!(NativeRequest::query, 0))?;
2036
- request_class.define_method("raw_query", method!(NativeRequest::raw_query, 0))?;
2037
- request_class.define_method("headers", method!(NativeRequest::headers, 0))?;
2038
- request_class.define_method("cookies", method!(NativeRequest::cookies, 0))?;
2039
- request_class.define_method("body", method!(NativeRequest::body, 0))?;
2040
- request_class.define_method("raw_body", method!(NativeRequest::raw_body, 0))?;
2041
- request_class.define_method("params", method!(NativeRequest::params, 0))?;
2042
- request_class.define_method("to_h", method!(NativeRequest::to_h, 0))?;
2043
- request_class.define_method("[]", method!(NativeRequest::index, 1))?;
2044
-
2045
- let lifecycle_registry_class = native.define_class("LifecycleRegistry", ruby.class_object())?;
2046
- lifecycle_registry_class.define_alloc_func::<NativeLifecycleRegistry>();
2047
- lifecycle_registry_class.define_method("add_on_request", method!(NativeLifecycleRegistry::add_on_request, 1))?;
2048
- lifecycle_registry_class.define_method(
2049
- "add_pre_validation",
2050
- method!(NativeLifecycleRegistry::add_pre_validation, 1),
2051
- )?;
2052
- lifecycle_registry_class.define_method(
2053
- "pre_validation",
2054
- method!(NativeLifecycleRegistry::add_pre_validation, 1),
2055
- )?;
2056
- lifecycle_registry_class.define_method("add_pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
2057
- lifecycle_registry_class.define_method("pre_handler", method!(NativeLifecycleRegistry::add_pre_handler, 1))?;
2058
- lifecycle_registry_class.define_method("add_on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
2059
- lifecycle_registry_class.define_method("on_response", method!(NativeLifecycleRegistry::add_on_response, 1))?;
2060
- lifecycle_registry_class.define_method("add_on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
2061
- lifecycle_registry_class.define_method("on_error", method!(NativeLifecycleRegistry::add_on_error, 1))?;
2062
-
2063
- let dependency_registry_class = native.define_class("DependencyRegistry", ruby.class_object())?;
2064
- dependency_registry_class.define_alloc_func::<NativeDependencyRegistry>();
2065
- dependency_registry_class.define_method("register_value", method!(NativeDependencyRegistry::register_value, 2))?;
2066
- dependency_registry_class.define_method(
2067
- "register_factory",
2068
- method!(NativeDependencyRegistry::register_factory, 5),
2069
- )?;
2070
- dependency_registry_class.define_method("keys", method!(NativeDependencyRegistry::keys, 0))?;
2071
- dependency_registry_class.define_method("resolve", method!(NativeDependencyRegistry::resolve, 1))?;
2072
-
2073
- let spikard_module = ruby.define_module("Spikard")?;
2074
- testing::websocket::init(ruby, &spikard_module)?;
2075
- testing::sse::init(ruby, &spikard_module)?;
2076
- grpc::handler::init(ruby, &spikard_module)?;
2077
-
2078
- let _ = NativeBuiltResponse::mark as fn(&NativeBuiltResponse, &Marker);
2079
- let _ = NativeLifecycleRegistry::mark as fn(&NativeLifecycleRegistry, &Marker);
2080
- let _ = NativeDependencyRegistry::mark as fn(&NativeDependencyRegistry, &Marker);
2081
- let _ = NativeRequest::mark as fn(&NativeRequest, &Marker);
2082
- let _ = RubyHandler::mark as fn(&RubyHandler, &Marker);
2083
- let _ = mark as fn(&NativeTestClient, &Marker);
2084
-
2085
- Ok(())
2086
- }
2087
-
2088
- #[cfg(test)]
2089
- mod tests {
2090
- use serde_json::json;
2091
-
2092
- /// Test that NativeBuiltResponse can extract parts safely
2093
- #[test]
2094
- fn test_native_built_response_status_extraction() {
2095
- use axum::http::StatusCode;
2096
-
2097
- let valid_codes = vec![200u16, 201, 204, 301, 400, 404, 500, 503];
2098
- for code in valid_codes {
2099
- let status = StatusCode::from_u16(code);
2100
- assert!(status.is_ok(), "Status code {} should be valid", code);
2101
- }
2102
- }
2103
-
2104
- /// Test that invalid status codes are rejected
2105
- #[test]
2106
- fn test_native_built_response_invalid_status() {
2107
- use axum::http::StatusCode;
2108
-
2109
- assert!(StatusCode::from_u16(599).is_ok(), "599 should be valid");
2110
- }
2111
-
2112
- /// Test HeaderName/HeaderValue construction
2113
- #[test]
2114
- fn test_header_construction() {
2115
- use axum::http::{HeaderName, HeaderValue};
2116
-
2117
- let valid_headers = vec![
2118
- ("X-Custom-Header", "value"),
2119
- ("Content-Type", "application/json"),
2120
- ("Cache-Control", "no-cache"),
2121
- ("Accept", "*/*"),
2122
- ];
2123
-
2124
- for (name, value) in valid_headers {
2125
- let header_name = HeaderName::from_bytes(name.as_bytes());
2126
- let header_value = HeaderValue::from_str(value);
2127
-
2128
- assert!(header_name.is_ok(), "Header name '{}' should be valid", name);
2129
- assert!(header_value.is_ok(), "Header value '{}' should be valid", value);
2130
- }
2131
- }
2132
-
2133
- /// Test invalid headers are rejected
2134
- #[test]
2135
- fn test_invalid_header_construction() {
2136
- use axum::http::{HeaderName, HeaderValue};
2137
-
2138
- let invalid_name = "X\nInvalid";
2139
- assert!(
2140
- HeaderName::from_bytes(invalid_name.as_bytes()).is_err(),
2141
- "Header with newline should be invalid"
2142
- );
2143
-
2144
- let invalid_value = "value\x00invalid";
2145
- assert!(
2146
- HeaderValue::from_str(invalid_value).is_err(),
2147
- "Header with null byte should be invalid"
2148
- );
2149
- }
2150
-
2151
- /// Test JSON serialization for responses
2152
- #[test]
2153
- fn test_json_response_serialization() {
2154
- let json_obj = json!({
2155
- "status": "success",
2156
- "data": [1, 2, 3],
2157
- "nested": {
2158
- "key": "value"
2159
- }
2160
- });
2161
-
2162
- let serialized = serde_json::to_vec(&json_obj);
2163
- assert!(serialized.is_ok(), "JSON should serialize");
2164
-
2165
- let bytes = serialized.expect("JSON should serialize");
2166
- assert!(!bytes.is_empty(), "Serialized JSON should not be empty");
2167
- }
2168
-
2169
- /// Test global runtime initialization
2170
- #[test]
2171
- fn test_global_runtime_initialization() {
2172
- assert!(crate::server::global_runtime_raw().is_ok());
2173
- }
2174
-
2175
- /// Test path normalization logic for routes
2176
- #[test]
2177
- fn test_route_path_patterns() {
2178
- let paths = vec![
2179
- "/users",
2180
- "/users/:id",
2181
- "/users/:id/posts/:post_id",
2182
- "/api/v1/resource",
2183
- "/api-v2/users_list",
2184
- "/resource.json",
2185
- ];
2186
-
2187
- for path in paths {
2188
- assert!(!path.is_empty());
2189
- assert!(path.starts_with('/'));
2190
- }
2191
- }
2192
-
2193
- /// Test HTTP method name validation
2194
- #[test]
2195
- fn test_http_method_names() {
2196
- let methods = vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
2197
-
2198
- for method in methods {
2199
- assert!(!method.is_empty());
2200
- assert!(method.chars().all(|c| c.is_uppercase()));
2201
- }
2202
- }
2203
-
2204
- /// Test handler name generation
2205
- #[test]
2206
- fn test_handler_name_patterns() {
2207
- let handler_names = vec![
2208
- "list_users",
2209
- "get_user",
2210
- "create_user",
2211
- "update_user",
2212
- "delete_user",
2213
- "get_user_posts",
2214
- ];
2215
-
2216
- for name in handler_names {
2217
- assert!(!name.is_empty());
2218
- assert!(name.chars().all(|c| c.is_alphanumeric() || c == '_'));
2219
- }
2220
- }
2221
-
2222
- /// Test multipart file handling structure
2223
- #[test]
2224
- fn test_multipart_file_part_structure() {
2225
- let file_data = spikard_http::testing::MultipartFilePart {
2226
- field_name: "file".to_string(),
2227
- filename: "test.txt".to_string(),
2228
- content: b"file content".to_vec(),
2229
- content_type: Some("text/plain".to_string()),
2230
- };
2231
-
2232
- assert_eq!(file_data.field_name, "file");
2233
- assert_eq!(file_data.filename, "test.txt");
2234
- assert!(!file_data.content.is_empty());
2235
- assert_eq!(file_data.content_type, Some("text/plain".to_string()));
2236
- }
2237
-
2238
- /// Test response header case sensitivity concepts
2239
- #[test]
2240
- fn test_response_header_concepts() {
2241
- use axum::http::HeaderName;
2242
-
2243
- let names = vec!["content-type", "Content-Type", "CONTENT-TYPE"];
2244
-
2245
- for name in names {
2246
- let parsed = HeaderName::from_bytes(name.as_bytes());
2247
- assert!(parsed.is_ok(), "Header name should parse: {}", name);
2248
- }
2249
- }
2250
-
2251
- /// Test error payload structure
2252
- #[test]
2253
- fn test_error_payload_structure() {
2254
- let error_json = json!({
2255
- "error": "Not Found",
2256
- "code": "404",
2257
- "details": {}
2258
- });
2259
-
2260
- assert_eq!(error_json["error"], "Not Found");
2261
- assert_eq!(error_json["code"], "404");
2262
- assert!(error_json["details"].is_object());
2263
- }
2264
- }