spikard 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +674 -674
  4. data/ext/spikard_rb/Cargo.toml +17 -17
  5. data/ext/spikard_rb/extconf.rb +13 -10
  6. data/ext/spikard_rb/src/lib.rs +6 -6
  7. data/lib/spikard/app.rb +405 -405
  8. data/lib/spikard/background.rb +27 -27
  9. data/lib/spikard/config.rb +396 -396
  10. data/lib/spikard/converters.rb +13 -13
  11. data/lib/spikard/handler_wrapper.rb +113 -113
  12. data/lib/spikard/provide.rb +214 -214
  13. data/lib/spikard/response.rb +173 -173
  14. data/lib/spikard/schema.rb +243 -243
  15. data/lib/spikard/sse.rb +111 -111
  16. data/lib/spikard/streaming_response.rb +44 -44
  17. data/lib/spikard/testing.rb +256 -256
  18. data/lib/spikard/upload_file.rb +131 -131
  19. data/lib/spikard/version.rb +5 -5
  20. data/lib/spikard/websocket.rb +59 -59
  21. data/lib/spikard.rb +43 -43
  22. data/sig/spikard.rbs +366 -366
  23. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -63
  24. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -132
  25. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -752
  26. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -194
  27. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -246
  28. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -401
  29. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -238
  30. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -24
  31. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -292
  32. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -616
  33. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -305
  34. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -248
  35. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -351
  36. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -454
  37. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -383
  38. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -280
  39. data/vendor/crates/spikard-core/Cargo.toml +40 -40
  40. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
  41. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
  42. data/vendor/crates/spikard-core/src/debug.rs +127 -127
  43. data/vendor/crates/spikard-core/src/di/container.rs +702 -702
  44. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
  45. data/vendor/crates/spikard-core/src/di/error.rs +118 -118
  46. data/vendor/crates/spikard-core/src/di/factory.rs +534 -534
  47. data/vendor/crates/spikard-core/src/di/graph.rs +506 -506
  48. data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
  49. data/vendor/crates/spikard-core/src/di/resolved.rs +405 -405
  50. data/vendor/crates/spikard-core/src/di/value.rs +281 -281
  51. data/vendor/crates/spikard-core/src/errors.rs +69 -69
  52. data/vendor/crates/spikard-core/src/http.rs +415 -415
  53. data/vendor/crates/spikard-core/src/lib.rs +29 -29
  54. data/vendor/crates/spikard-core/src/lifecycle.rs +1186 -1186
  55. data/vendor/crates/spikard-core/src/metadata.rs +389 -389
  56. data/vendor/crates/spikard-core/src/parameters.rs +2525 -2525
  57. data/vendor/crates/spikard-core/src/problem.rs +344 -344
  58. data/vendor/crates/spikard-core/src/request_data.rs +1154 -1154
  59. data/vendor/crates/spikard-core/src/router.rs +510 -510
  60. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
  61. data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
  62. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +696 -688
  63. data/vendor/crates/spikard-core/src/validation/mod.rs +457 -457
  64. data/vendor/crates/spikard-http/Cargo.toml +62 -64
  65. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -148
  66. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -92
  67. data/vendor/crates/spikard-http/src/auth.rs +296 -296
  68. data/vendor/crates/spikard-http/src/background.rs +1860 -1860
  69. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
  70. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
  71. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
  72. data/vendor/crates/spikard-http/src/cors.rs +1005 -1005
  73. data/vendor/crates/spikard-http/src/debug.rs +128 -128
  74. data/vendor/crates/spikard-http/src/di_handler.rs +1668 -1668
  75. data/vendor/crates/spikard-http/src/handler_response.rs +901 -901
  76. data/vendor/crates/spikard-http/src/handler_trait.rs +838 -830
  77. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +290 -290
  78. data/vendor/crates/spikard-http/src/lib.rs +534 -534
  79. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +230 -230
  80. data/vendor/crates/spikard-http/src/lifecycle.rs +1193 -1193
  81. data/vendor/crates/spikard-http/src/middleware/mod.rs +560 -540
  82. data/vendor/crates/spikard-http/src/middleware/multipart.rs +912 -912
  83. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +513 -513
  84. data/vendor/crates/spikard-http/src/middleware/validation.rs +768 -735
  85. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
  86. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -535
  87. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1363 -1363
  88. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +665 -665
  89. data/vendor/crates/spikard-http/src/query_parser.rs +793 -793
  90. data/vendor/crates/spikard-http/src/response.rs +720 -720
  91. data/vendor/crates/spikard-http/src/server/handler.rs +1650 -1650
  92. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +234 -234
  93. data/vendor/crates/spikard-http/src/server/mod.rs +1593 -1502
  94. data/vendor/crates/spikard-http/src/server/request_extraction.rs +789 -770
  95. data/vendor/crates/spikard-http/src/server/routing_factory.rs +629 -599
  96. data/vendor/crates/spikard-http/src/sse.rs +1409 -1409
  97. data/vendor/crates/spikard-http/src/testing/form.rs +52 -52
  98. data/vendor/crates/spikard-http/src/testing/multipart.rs +64 -60
  99. data/vendor/crates/spikard-http/src/testing/test_client.rs +311 -283
  100. data/vendor/crates/spikard-http/src/testing.rs +406 -377
  101. data/vendor/crates/spikard-http/src/websocket.rs +1404 -1375
  102. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -832
  103. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -309
  104. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -26
  105. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -192
  106. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -5
  107. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -1093
  108. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -656
  109. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -314
  110. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -620
  111. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -663
  112. data/vendor/crates/spikard-rb/Cargo.toml +48 -48
  113. data/vendor/crates/spikard-rb/build.rs +199 -199
  114. data/vendor/crates/spikard-rb/src/background.rs +63 -63
  115. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -5
  116. data/vendor/crates/spikard-rb/src/config/server_config.rs +285 -285
  117. data/vendor/crates/spikard-rb/src/conversion.rs +554 -554
  118. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -100
  119. data/vendor/crates/spikard-rb/src/di/mod.rs +375 -375
  120. data/vendor/crates/spikard-rb/src/handler.rs +618 -618
  121. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -3
  122. data/vendor/crates/spikard-rb/src/lib.rs +1806 -1810
  123. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -275
  124. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -5
  125. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +442 -447
  126. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -5
  127. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -324
  128. data/vendor/crates/spikard-rb/src/server.rs +305 -308
  129. data/vendor/crates/spikard-rb/src/sse.rs +231 -231
  130. data/vendor/crates/spikard-rb/src/testing/client.rs +538 -551
  131. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -7
  132. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -143
  133. data/vendor/crates/spikard-rb/src/testing/websocket.rs +608 -635
  134. data/vendor/crates/spikard-rb/src/websocket.rs +377 -374
  135. metadata +15 -1
@@ -1,635 +1,608 @@
1
- //! WebSocket test client bindings for Ruby
2
-
3
- use magnus::prelude::*;
4
- use magnus::{Error, Ruby, Value, method};
5
- use serde_json::Value as JsonValue;
6
- use std::cell::RefCell;
7
- use std::net::{TcpStream, ToSocketAddrs};
8
- use std::time::Duration;
9
- use tungstenite::{Message, WebSocket};
10
- use tungstenite::stream::MaybeTlsStream;
11
- use url::Url;
12
-
13
- #[derive(Debug)]
14
- pub(crate) enum WebSocketIoError {
15
- Timeout,
16
- Closed,
17
- Other(String),
18
- }
19
-
20
- #[derive(Debug)]
21
- pub(crate) struct WebSocketConnection {
22
- stream: WebSocket<MaybeTlsStream<TcpStream>>,
23
- }
24
-
25
- impl WebSocketConnection {
26
- pub(crate) fn connect(url: Url, timeout: Duration) -> Result<Self, WebSocketIoError> {
27
- if url.scheme() == "wss" {
28
- return Err(WebSocketIoError::Other(
29
- "wss is not supported for Ruby test sockets".to_string(),
30
- ));
31
- }
32
-
33
- let host = url
34
- .host_str()
35
- .ok_or_else(|| WebSocketIoError::Other("Missing WebSocket host".to_string()))?;
36
- let port = url
37
- .port_or_known_default()
38
- .ok_or_else(|| WebSocketIoError::Other("Missing WebSocket port".to_string()))?;
39
- let addr = (host, port)
40
- .to_socket_addrs()
41
- .map_err(|err| WebSocketIoError::Other(err.to_string()))?
42
- .next()
43
- .ok_or_else(|| WebSocketIoError::Other("Unable to resolve WebSocket host".to_string()))?;
44
- let tcp_stream = TcpStream::connect_timeout(&addr, timeout)
45
- .map_err(map_io_error)?;
46
- tcp_stream
47
- .set_read_timeout(Some(timeout))
48
- .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
49
- tcp_stream
50
- .set_write_timeout(Some(timeout))
51
- .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
52
-
53
- let request = url.as_str();
54
- let (stream, _) = tungstenite::client::client(request, MaybeTlsStream::Plain(tcp_stream))
55
- .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
56
- Ok(Self { stream })
57
- }
58
-
59
- pub(crate) fn send_text(&mut self, text: String) -> Result<(), WebSocketIoError> {
60
- self.stream
61
- .write_message(Message::Text(text))
62
- .map_err(map_tungstenite_error)
63
- }
64
-
65
- pub(crate) fn send_json(&mut self, json_value: &JsonValue) -> Result<(), WebSocketIoError> {
66
- let text = serde_json::to_string(json_value)
67
- .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
68
- self.send_text(text)
69
- }
70
-
71
- pub(crate) fn receive_message(&mut self) -> Result<Message, WebSocketIoError> {
72
- match self.stream.read_message() {
73
- Ok(message) => Ok(message),
74
- Err(err) => Err(map_tungstenite_error(err)),
75
- }
76
- }
77
-
78
- pub(crate) fn receive_text(&mut self) -> Result<String, WebSocketIoError> {
79
- let message = self.receive_message()?;
80
- message_to_text(message)
81
- }
82
-
83
- pub(crate) fn receive_json(&mut self) -> Result<JsonValue, WebSocketIoError> {
84
- let text = self.receive_text()?;
85
- serde_json::from_str(&text).map_err(|err| WebSocketIoError::Other(err.to_string()))
86
- }
87
-
88
- pub(crate) fn receive_bytes(&mut self) -> Result<bytes::Bytes, WebSocketIoError> {
89
- let message = self.receive_message()?;
90
- message_to_bytes(message)
91
- }
92
-
93
- pub(crate) fn close(mut self) -> Result<(), WebSocketIoError> {
94
- self.stream
95
- .close(None)
96
- .map_err(map_tungstenite_error)
97
- }
98
- }
99
-
100
- fn map_tungstenite_error(err: tungstenite::Error) -> WebSocketIoError {
101
- match err {
102
- tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed => WebSocketIoError::Closed,
103
- tungstenite::Error::Io(io_err) => match io_err.kind() {
104
- std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => WebSocketIoError::Timeout,
105
- _ => WebSocketIoError::Other(io_err.to_string()),
106
- },
107
- other => WebSocketIoError::Other(other.to_string()),
108
- }
109
- }
110
-
111
- fn map_io_error(err: std::io::Error) -> WebSocketIoError {
112
- match err.kind() {
113
- std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => WebSocketIoError::Timeout,
114
- _ => WebSocketIoError::Other(err.to_string()),
115
- }
116
- }
117
-
118
- fn message_to_text(message: Message) -> Result<String, WebSocketIoError> {
119
- match message {
120
- Message::Text(text) => Ok(text),
121
- Message::Binary(bytes) => String::from_utf8(bytes)
122
- .map_err(|err| WebSocketIoError::Other(err.to_string())),
123
- Message::Close(frame) => Ok(frame.map(|f| f.reason.to_string()).unwrap_or_default()),
124
- Message::Ping(_) | Message::Pong(_) => Ok(String::new()),
125
- Message::Frame(_) => Err(WebSocketIoError::Other(
126
- "Unexpected frame message while reading text".to_string(),
127
- )),
128
- }
129
- }
130
-
131
- fn message_to_bytes(message: Message) -> Result<bytes::Bytes, WebSocketIoError> {
132
- match message {
133
- Message::Text(text) => Ok(bytes::Bytes::from(text)),
134
- Message::Binary(bytes) => Ok(bytes::Bytes::from(bytes)),
135
- Message::Close(frame) => Ok(bytes::Bytes::from(
136
- frame.map(|f| f.reason.to_string()).unwrap_or_default(),
137
- )),
138
- Message::Ping(data) => Ok(bytes::Bytes::from(data)),
139
- Message::Pong(data) => Ok(bytes::Bytes::from(data)),
140
- Message::Frame(_) => Err(WebSocketIoError::Other(
141
- "Unexpected frame message while reading bytes".to_string(),
142
- )),
143
- }
144
- }
145
-
146
- /// Ruby wrapper for WebSocket test client
147
- #[derive(Default)]
148
- #[magnus::wrap(class = "Spikard::Native::WebSocketTestConnection", free_immediately)]
149
- pub struct WebSocketTestConnection {
150
- inner: RefCell<Option<WebSocketConnection>>,
151
- }
152
-
153
- impl WebSocketTestConnection {
154
- /// Create a new WebSocket test connection (public for lib.rs)
155
- pub(crate) fn new(inner: WebSocketConnection) -> Self {
156
- Self {
157
- inner: RefCell::new(Some(inner)),
158
- }
159
- }
160
-
161
- /// Send a text message
162
- fn send_text(&self, text: String) -> Result<(), Error> {
163
- let mut inner = self.inner.borrow_mut();
164
- let ws = inner
165
- .as_mut()
166
- .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
167
-
168
- let timeout_duration = websocket_timeout();
169
- let result = crate::call_without_gvl!(
170
- block_on_send_text,
171
- args: (timeout_duration, Duration, ws, &mut WebSocketConnection, text, String),
172
- return_type: Result<(), WebSocketIoError>
173
- );
174
- match result {
175
- Ok(()) => Ok(()),
176
- Err(WebSocketIoError::Timeout) => Err(Error::new(
177
- magnus::exception::runtime_error(),
178
- format!(
179
- "WebSocket send timed out after {}ms",
180
- timeout_duration.as_millis()
181
- ),
182
- )),
183
- Err(WebSocketIoError::Closed) => Err(Error::new(
184
- magnus::exception::runtime_error(),
185
- "WebSocket connection closed".to_string(),
186
- )),
187
- Err(WebSocketIoError::Other(message)) => Err(Error::new(
188
- magnus::exception::runtime_error(),
189
- format!("WebSocket send failed: {}", message),
190
- )),
191
- }?;
192
-
193
- Ok(())
194
- }
195
-
196
- /// Send a JSON message
197
- fn send_json(ruby: &Ruby, this: &Self, obj: Value) -> Result<(), Error> {
198
- let json_value = ruby_to_json(ruby, obj)?;
199
- let mut inner = this.inner.borrow_mut();
200
- let ws = inner
201
- .as_mut()
202
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
203
-
204
- let timeout_duration = websocket_timeout();
205
- let json_value_ref = &json_value;
206
- let result = crate::call_without_gvl!(
207
- block_on_send_json,
208
- args: (
209
- timeout_duration, Duration,
210
- ws, &mut WebSocketConnection,
211
- json_value_ref, &JsonValue
212
- ),
213
- return_type: Result<(), WebSocketIoError>
214
- );
215
- match result {
216
- Ok(()) => Ok(()),
217
- Err(WebSocketIoError::Timeout) => Err(Error::new(
218
- ruby.exception_runtime_error(),
219
- format!(
220
- "WebSocket send timed out after {}ms",
221
- timeout_duration.as_millis()
222
- ),
223
- )),
224
- Err(WebSocketIoError::Closed) => Err(Error::new(
225
- ruby.exception_runtime_error(),
226
- "WebSocket connection closed".to_string(),
227
- )),
228
- Err(WebSocketIoError::Other(message)) => Err(Error::new(
229
- ruby.exception_runtime_error(),
230
- format!("WebSocket send failed: {}", message),
231
- )),
232
- }?;
233
-
234
- Ok(())
235
- }
236
-
237
- /// Receive a text message
238
- fn receive_text(&self) -> Result<String, Error> {
239
- let mut inner = self.inner.borrow_mut();
240
- let ws = inner
241
- .as_mut()
242
- .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
243
-
244
- let timeout_duration = websocket_timeout();
245
- let text = crate::call_without_gvl!(
246
- block_on_receive_text,
247
- args: (
248
- timeout_duration, Duration,
249
- ws, &mut WebSocketConnection
250
- ),
251
- return_type: Result<String, WebSocketIoError>
252
- )
253
- .map_err(|err| match err {
254
- WebSocketIoError::Timeout => Error::new(
255
- magnus::exception::runtime_error(),
256
- format!(
257
- "WebSocket receive timed out after {}ms",
258
- timeout_duration.as_millis()
259
- ),
260
- ),
261
- WebSocketIoError::Closed => Error::new(
262
- magnus::exception::runtime_error(),
263
- "WebSocket connection closed".to_string(),
264
- ),
265
- WebSocketIoError::Other(message) => Error::new(
266
- magnus::exception::runtime_error(),
267
- format!("WebSocket receive failed: {}", message),
268
- ),
269
- })?;
270
-
271
- Ok(text)
272
- }
273
-
274
- /// Receive and parse a JSON message
275
- fn receive_json(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
276
- let mut inner = this.inner.borrow_mut();
277
- let ws = inner
278
- .as_mut()
279
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
280
-
281
- let timeout_duration = websocket_timeout();
282
- let json_value = crate::call_without_gvl!(
283
- block_on_receive_json,
284
- args: (
285
- timeout_duration, Duration,
286
- ws, &mut WebSocketConnection
287
- ),
288
- return_type: Result<JsonValue, WebSocketIoError>
289
- )
290
- .map_err(|err| match err {
291
- WebSocketIoError::Timeout => Error::new(
292
- ruby.exception_runtime_error(),
293
- format!(
294
- "WebSocket receive timed out after {}ms",
295
- timeout_duration.as_millis()
296
- ),
297
- ),
298
- WebSocketIoError::Closed => Error::new(
299
- ruby.exception_runtime_error(),
300
- "WebSocket connection closed".to_string(),
301
- ),
302
- WebSocketIoError::Other(message) => Error::new(
303
- ruby.exception_runtime_error(),
304
- format!("WebSocket receive failed: {}", message),
305
- ),
306
- })?;
307
-
308
- json_to_ruby(ruby, &json_value)
309
- }
310
-
311
- /// Receive raw bytes
312
- fn receive_bytes(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
313
- let mut inner = this.inner.borrow_mut();
314
- let ws = inner
315
- .as_mut()
316
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
317
-
318
- let timeout_duration = websocket_timeout();
319
- let result = crate::call_without_gvl!(
320
- block_on_receive_bytes,
321
- args: (
322
- timeout_duration, Duration,
323
- ws, &mut WebSocketConnection
324
- ),
325
- return_type: Result<bytes::Bytes, WebSocketIoError>
326
- );
327
- let bytes = result.map_err(|err| match err {
328
- WebSocketIoError::Timeout => Error::new(
329
- ruby.exception_runtime_error(),
330
- format!(
331
- "WebSocket receive timed out after {}ms",
332
- timeout_duration.as_millis()
333
- ),
334
- ),
335
- WebSocketIoError::Closed => Error::new(
336
- ruby.exception_runtime_error(),
337
- "WebSocket connection closed".to_string(),
338
- ),
339
- WebSocketIoError::Other(message) => Error::new(
340
- ruby.exception_runtime_error(),
341
- format!("WebSocket receive failed: {}", message),
342
- ),
343
- })?;
344
-
345
- Ok(ruby.str_from_slice(&bytes).as_value())
346
- }
347
-
348
- /// Receive a message and return WebSocketMessage
349
- fn receive_message(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
350
- let mut inner = this.inner.borrow_mut();
351
- let ws = inner
352
- .as_mut()
353
- .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
354
-
355
- let timeout_duration = websocket_timeout();
356
- let result = crate::call_without_gvl!(
357
- block_on_receive_message,
358
- args: (
359
- timeout_duration, Duration,
360
- ws, &mut WebSocketConnection
361
- ),
362
- return_type: Result<WebSocketMessageData, WebSocketIoError>
363
- );
364
- let msg = result.map_err(|err| match err {
365
- WebSocketIoError::Timeout => Error::new(
366
- ruby.exception_runtime_error(),
367
- format!(
368
- "WebSocket receive timed out after {}ms",
369
- timeout_duration.as_millis()
370
- ),
371
- ),
372
- WebSocketIoError::Closed => Error::new(
373
- ruby.exception_runtime_error(),
374
- "WebSocket connection closed".to_string(),
375
- ),
376
- WebSocketIoError::Other(message) => Error::new(
377
- ruby.exception_runtime_error(),
378
- format!("WebSocket receive failed: {}", message),
379
- ),
380
- })?;
381
-
382
- let ws_msg = WebSocketMessage::new(msg);
383
- Ok(ruby.obj_wrap(ws_msg).as_value())
384
- }
385
-
386
- /// Close the WebSocket connection
387
- fn close(&self) -> Result<(), Error> {
388
- let mut inner = self.inner.borrow_mut();
389
- let ws = inner
390
- .take()
391
- .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
392
- let result = crate::call_without_gvl!(
393
- block_on_close,
394
- args: (ws, WebSocketConnection),
395
- return_type: Result<(), WebSocketIoError>
396
- );
397
- result.map_err(|err| match err {
398
- WebSocketIoError::Timeout => Error::new(
399
- magnus::exception::runtime_error(),
400
- "WebSocket close timed out".to_string(),
401
- ),
402
- WebSocketIoError::Closed => Error::new(
403
- magnus::exception::runtime_error(),
404
- "WebSocket connection closed".to_string(),
405
- ),
406
- WebSocketIoError::Other(message) => Error::new(
407
- magnus::exception::runtime_error(),
408
- format!("WebSocket close failed: {}", message),
409
- ),
410
- })
411
- }
412
- }
413
-
414
- fn block_on_send_text(
415
- timeout_duration: Duration,
416
- ws: &mut WebSocketConnection,
417
- text: String,
418
- ) -> Result<(), WebSocketIoError> {
419
- set_stream_timeouts(ws, timeout_duration)?;
420
- ws.send_text(text)
421
- }
422
-
423
- fn block_on_send_json(
424
- timeout_duration: Duration,
425
- ws: &mut WebSocketConnection,
426
- json_value: &JsonValue,
427
- ) -> Result<(), WebSocketIoError> {
428
- set_stream_timeouts(ws, timeout_duration)?;
429
- ws.send_json(json_value)
430
- }
431
-
432
- fn block_on_receive_bytes(
433
- timeout_duration: Duration,
434
- ws: &mut WebSocketConnection,
435
- ) -> Result<bytes::Bytes, WebSocketIoError> {
436
- set_stream_timeouts(ws, timeout_duration)?;
437
- ws.receive_bytes()
438
- }
439
-
440
- fn block_on_receive_text(
441
- timeout_duration: Duration,
442
- ws: &mut WebSocketConnection,
443
- ) -> Result<String, WebSocketIoError> {
444
- set_stream_timeouts(ws, timeout_duration)?;
445
- ws.receive_text()
446
- }
447
-
448
- fn block_on_receive_json(
449
- timeout_duration: Duration,
450
- ws: &mut WebSocketConnection,
451
- ) -> Result<JsonValue, WebSocketIoError> {
452
- set_stream_timeouts(ws, timeout_duration)?;
453
- ws.receive_json()
454
- }
455
-
456
- fn block_on_receive_message(
457
- timeout_duration: Duration,
458
- ws: &mut WebSocketConnection,
459
- ) -> Result<WebSocketMessageData, WebSocketIoError> {
460
- set_stream_timeouts(ws, timeout_duration)?;
461
- ws.receive_message().and_then(WebSocketMessageData::from_tungstenite)
462
- }
463
-
464
- fn block_on_close(ws: WebSocketConnection) -> Result<(), WebSocketIoError> {
465
- ws.close()
466
- }
467
-
468
- fn set_stream_timeouts(ws: &mut WebSocketConnection, timeout: Duration) -> Result<(), WebSocketIoError> {
469
- match ws.stream.get_mut() {
470
- MaybeTlsStream::Plain(stream) => {
471
- stream
472
- .set_read_timeout(Some(timeout))
473
- .map_err(|e| WebSocketIoError::Other(e.to_string()))?;
474
- stream
475
- .set_write_timeout(Some(timeout))
476
- .map_err(|e| WebSocketIoError::Other(e.to_string()))?;
477
- }
478
- _ => {}
479
- }
480
- Ok(())
481
- }
482
-
483
- fn websocket_timeout() -> Duration {
484
- const DEFAULT_TIMEOUT_MS: u64 = 30_000;
485
- let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
486
- .ok()
487
- .and_then(|value| value.parse::<u64>().ok())
488
- .unwrap_or(DEFAULT_TIMEOUT_MS);
489
- Duration::from_millis(timeout_ms)
490
- }
491
-
492
- /// Ruby wrapper for WebSocket messages
493
- #[allow(dead_code)]
494
- #[derive(Debug, Clone)]
495
- pub enum WebSocketMessageData {
496
- Text(String),
497
- Binary(Vec<u8>),
498
- Close(Option<String>),
499
- Ping(Vec<u8>),
500
- Pong(Vec<u8>),
501
- }
502
-
503
- impl WebSocketMessageData {
504
- fn from_tungstenite(message: Message) -> Result<Self, WebSocketIoError> {
505
- match message {
506
- Message::Text(text) => Ok(Self::Text(text)),
507
- Message::Binary(bytes) => Ok(Self::Binary(bytes)),
508
- Message::Close(frame) => Ok(Self::Close(frame.map(|f| f.reason.to_string()))),
509
- Message::Ping(bytes) => Ok(Self::Ping(bytes)),
510
- Message::Pong(bytes) => Ok(Self::Pong(bytes)),
511
- Message::Frame(_) => Err(WebSocketIoError::Other(
512
- "Unexpected frame message while reading WebSocket".to_string(),
513
- )),
514
- }
515
- }
516
- }
517
-
518
- #[magnus::wrap(class = "Spikard::Native::WebSocketMessage", free_immediately)]
519
- pub struct WebSocketMessage {
520
- inner: WebSocketMessageData,
521
- }
522
-
523
- impl WebSocketMessage {
524
- pub fn new(inner: WebSocketMessageData) -> Self {
525
- Self { inner }
526
- }
527
-
528
- /// Get message as text if it's a text message
529
- fn as_text(&self) -> Result<Option<String>, Error> {
530
- match &self.inner {
531
- WebSocketMessageData::Text(text) => Ok(Some(text.clone())),
532
- _ => Ok(None),
533
- }
534
- }
535
-
536
- /// Get message as JSON if it's a text message containing JSON
537
- fn as_json(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
538
- match &this.inner {
539
- WebSocketMessageData::Text(text) => match serde_json::from_str::<JsonValue>(text) {
540
- Ok(value) => json_to_ruby(ruby, &value),
541
- Err(_) => Ok(ruby.qnil().as_value()),
542
- },
543
- _ => Ok(ruby.qnil().as_value()),
544
- }
545
- }
546
-
547
- /// Get message as binary if it's a binary message
548
- fn as_binary(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
549
- match &this.inner {
550
- WebSocketMessageData::Binary(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
551
- WebSocketMessageData::Ping(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
552
- WebSocketMessageData::Pong(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
553
- _ => Ok(ruby.qnil().as_value()),
554
- }
555
- }
556
-
557
- /// Check if this is a close message
558
- fn is_close(&self) -> bool {
559
- matches!(self.inner, WebSocketMessageData::Close(_))
560
- }
561
- }
562
-
563
- /// Helper to convert Ruby object to JSON
564
- fn ruby_to_json(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
565
- let json_module = ruby.class_object().const_get::<_, magnus::RModule>("JSON")?;
566
- let json_str: String = json_module.funcall("generate", (value,))?;
567
- serde_json::from_str(&json_str).map_err(|e| {
568
- Error::new(
569
- magnus::exception::runtime_error(),
570
- format!("Failed to parse JSON: {}", e),
571
- )
572
- })
573
- }
574
-
575
- /// Helper to convert JSON to Ruby object
576
- fn json_to_ruby(ruby: &Ruby, value: &JsonValue) -> Result<Value, Error> {
577
- match value {
578
- JsonValue::Null => Ok(ruby.qnil().as_value()),
579
- JsonValue::Bool(b) => Ok(if *b {
580
- ruby.qtrue().as_value()
581
- } else {
582
- ruby.qfalse().as_value()
583
- }),
584
- JsonValue::Number(n) => {
585
- if let Some(i) = n.as_i64() {
586
- Ok(ruby.integer_from_i64(i).as_value())
587
- } else if let Some(u) = n.as_u64() {
588
- Ok(ruby.integer_from_i64(u as i64).as_value())
589
- } else if let Some(f) = n.as_f64() {
590
- Ok(ruby.float_from_f64(f).as_value())
591
- } else {
592
- Ok(ruby.qnil().as_value())
593
- }
594
- }
595
- JsonValue::String(s) => Ok(ruby.str_new(s).as_value()),
596
- JsonValue::Array(arr) => {
597
- let ruby_arr = ruby.ary_new();
598
- for item in arr {
599
- let ruby_val = json_to_ruby(ruby, item)?;
600
- ruby_arr.push(ruby_val)?;
601
- }
602
- Ok(ruby_arr.as_value())
603
- }
604
- JsonValue::Object(obj) => {
605
- let ruby_hash = ruby.hash_new();
606
- for (key, val) in obj {
607
- let ruby_val = json_to_ruby(ruby, val)?;
608
- ruby_hash.aset(ruby.str_new(key), ruby_val)?;
609
- }
610
- Ok(ruby_hash.as_value())
611
- }
612
- }
613
- }
614
-
615
- /// Initialize WebSocket test client bindings
616
- pub fn init(ruby: &Ruby, module: &magnus::RModule) -> Result<(), Error> {
617
- let native_module = module.define_module("Native")?;
618
-
619
- let ws_conn_class = native_module.define_class("WebSocketTestConnection", ruby.class_object())?;
620
- ws_conn_class.define_method("send_text", method!(WebSocketTestConnection::send_text, 1))?;
621
- ws_conn_class.define_method("send_json", method!(WebSocketTestConnection::send_json, 1))?;
622
- ws_conn_class.define_method("receive_text", method!(WebSocketTestConnection::receive_text, 0))?;
623
- ws_conn_class.define_method("receive_json", method!(WebSocketTestConnection::receive_json, 0))?;
624
- ws_conn_class.define_method("receive_bytes", method!(WebSocketTestConnection::receive_bytes, 0))?;
625
- ws_conn_class.define_method("receive_message", method!(WebSocketTestConnection::receive_message, 0))?;
626
- ws_conn_class.define_method("close", method!(WebSocketTestConnection::close, 0))?;
627
-
628
- let ws_msg_class = native_module.define_class("WebSocketMessage", ruby.class_object())?;
629
- ws_msg_class.define_method("as_text", method!(WebSocketMessage::as_text, 0))?;
630
- ws_msg_class.define_method("as_json", method!(WebSocketMessage::as_json, 0))?;
631
- ws_msg_class.define_method("as_binary", method!(WebSocketMessage::as_binary, 0))?;
632
- ws_msg_class.define_method("is_close", method!(WebSocketMessage::is_close, 0))?;
633
-
634
- Ok(())
635
- }
1
+ //! WebSocket test client bindings for Ruby
2
+
3
+ use magnus::prelude::*;
4
+ use magnus::{Error, Ruby, Value, method};
5
+ use serde_json::Value as JsonValue;
6
+ use std::cell::RefCell;
7
+ use std::net::{TcpStream, ToSocketAddrs};
8
+ use std::time::Duration;
9
+ use tungstenite::stream::MaybeTlsStream;
10
+ use tungstenite::{Message, WebSocket};
11
+ use url::Url;
12
+
13
+ #[derive(Debug)]
14
+ pub(crate) enum WebSocketIoError {
15
+ Timeout,
16
+ Closed,
17
+ Other(String),
18
+ }
19
+
20
+ #[derive(Debug)]
21
+ pub(crate) struct WebSocketConnection {
22
+ stream: WebSocket<MaybeTlsStream<TcpStream>>,
23
+ }
24
+
25
+ impl WebSocketConnection {
26
+ pub(crate) fn connect(url: Url, timeout: Duration) -> Result<Self, WebSocketIoError> {
27
+ if url.scheme() == "wss" {
28
+ return Err(WebSocketIoError::Other(
29
+ "wss is not supported for Ruby test sockets".to_string(),
30
+ ));
31
+ }
32
+
33
+ let host = url
34
+ .host_str()
35
+ .ok_or_else(|| WebSocketIoError::Other("Missing WebSocket host".to_string()))?;
36
+ let port = url
37
+ .port_or_known_default()
38
+ .ok_or_else(|| WebSocketIoError::Other("Missing WebSocket port".to_string()))?;
39
+ let addr = (host, port)
40
+ .to_socket_addrs()
41
+ .map_err(|err| WebSocketIoError::Other(err.to_string()))?
42
+ .next()
43
+ .ok_or_else(|| WebSocketIoError::Other("Unable to resolve WebSocket host".to_string()))?;
44
+ let tcp_stream = TcpStream::connect_timeout(&addr, timeout).map_err(map_io_error)?;
45
+ tcp_stream
46
+ .set_read_timeout(Some(timeout))
47
+ .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
48
+ tcp_stream
49
+ .set_write_timeout(Some(timeout))
50
+ .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
51
+
52
+ let request = url.as_str();
53
+ let (stream, _) = tungstenite::client::client(request, MaybeTlsStream::Plain(tcp_stream))
54
+ .map_err(|err| WebSocketIoError::Other(err.to_string()))?;
55
+ Ok(Self { stream })
56
+ }
57
+
58
+ pub(crate) fn send_text(&mut self, text: String) -> Result<(), WebSocketIoError> {
59
+ self.stream
60
+ .write_message(Message::Text(text.into()))
61
+ .map_err(map_tungstenite_error)
62
+ }
63
+
64
+ pub(crate) fn send_json(&mut self, json_value: &JsonValue) -> Result<(), WebSocketIoError> {
65
+ let text = serde_json::to_string(json_value).map_err(|err| WebSocketIoError::Other(err.to_string()))?;
66
+ self.send_text(text)
67
+ }
68
+
69
+ pub(crate) fn receive_message(&mut self) -> Result<Message, WebSocketIoError> {
70
+ match self.stream.read_message() {
71
+ Ok(message) => Ok(message),
72
+ Err(err) => Err(map_tungstenite_error(err)),
73
+ }
74
+ }
75
+
76
+ pub(crate) fn receive_text(&mut self) -> Result<String, WebSocketIoError> {
77
+ let message = self.receive_message()?;
78
+ message_to_text(message)
79
+ }
80
+
81
+ pub(crate) fn receive_json(&mut self) -> Result<JsonValue, WebSocketIoError> {
82
+ let text = self.receive_text()?;
83
+ serde_json::from_str(&text).map_err(|err| WebSocketIoError::Other(err.to_string()))
84
+ }
85
+
86
+ pub(crate) fn receive_bytes(&mut self) -> Result<bytes::Bytes, WebSocketIoError> {
87
+ let message = self.receive_message()?;
88
+ message_to_bytes(message)
89
+ }
90
+
91
+ pub(crate) fn close(mut self) -> Result<(), WebSocketIoError> {
92
+ self.stream.close(None).map_err(map_tungstenite_error)
93
+ }
94
+ }
95
+
96
+ fn map_tungstenite_error(err: tungstenite::Error) -> WebSocketIoError {
97
+ match err {
98
+ tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed => WebSocketIoError::Closed,
99
+ tungstenite::Error::Io(io_err) => match io_err.kind() {
100
+ std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => WebSocketIoError::Timeout,
101
+ _ => WebSocketIoError::Other(io_err.to_string()),
102
+ },
103
+ other => WebSocketIoError::Other(other.to_string()),
104
+ }
105
+ }
106
+
107
+ fn map_io_error(err: std::io::Error) -> WebSocketIoError {
108
+ match err.kind() {
109
+ std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => WebSocketIoError::Timeout,
110
+ _ => WebSocketIoError::Other(err.to_string()),
111
+ }
112
+ }
113
+
114
+ fn message_to_text(message: Message) -> Result<String, WebSocketIoError> {
115
+ match message {
116
+ Message::Text(text) => Ok(text.to_string()),
117
+ Message::Binary(bytes) => {
118
+ String::from_utf8(bytes.to_vec()).map_err(|err| WebSocketIoError::Other(err.to_string()))
119
+ }
120
+ Message::Close(frame) => Ok(frame.map(|f| f.reason.to_string()).unwrap_or_default()),
121
+ Message::Ping(_) | Message::Pong(_) => Ok(String::new()),
122
+ Message::Frame(_) => Err(WebSocketIoError::Other(
123
+ "Unexpected frame message while reading text".to_string(),
124
+ )),
125
+ }
126
+ }
127
+
128
+ fn message_to_bytes(message: Message) -> Result<bytes::Bytes, WebSocketIoError> {
129
+ match message {
130
+ Message::Text(text) => Ok(bytes::Bytes::from(text.to_string())),
131
+ Message::Binary(bytes) => Ok(bytes),
132
+ Message::Close(frame) => Ok(bytes::Bytes::from(
133
+ frame.map(|f| f.reason.to_string()).unwrap_or_default(),
134
+ )),
135
+ Message::Ping(data) => Ok(data),
136
+ Message::Pong(data) => Ok(data),
137
+ Message::Frame(_) => Err(WebSocketIoError::Other(
138
+ "Unexpected frame message while reading bytes".to_string(),
139
+ )),
140
+ }
141
+ }
142
+
143
+ /// Ruby wrapper for WebSocket test client
144
+ #[derive(Default)]
145
+ #[magnus::wrap(class = "Spikard::Native::WebSocketTestConnection", free_immediately)]
146
+ pub struct WebSocketTestConnection {
147
+ inner: RefCell<Option<WebSocketConnection>>,
148
+ }
149
+
150
+ impl WebSocketTestConnection {
151
+ /// Create a new WebSocket test connection (public for lib.rs)
152
+ pub(crate) fn new(inner: WebSocketConnection) -> Self {
153
+ Self {
154
+ inner: RefCell::new(Some(inner)),
155
+ }
156
+ }
157
+
158
+ /// Send a text message
159
+ fn send_text(&self, text: String) -> Result<(), Error> {
160
+ let mut inner = self.inner.borrow_mut();
161
+ let ws = inner
162
+ .as_mut()
163
+ .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
164
+
165
+ let timeout_duration = websocket_timeout();
166
+ let result = crate::call_without_gvl!(
167
+ block_on_send_text,
168
+ args: (timeout_duration, Duration, ws, &mut WebSocketConnection, text, String),
169
+ return_type: Result<(), WebSocketIoError>
170
+ );
171
+ match result {
172
+ Ok(()) => Ok(()),
173
+ Err(WebSocketIoError::Timeout) => Err(Error::new(
174
+ magnus::exception::runtime_error(),
175
+ format!("WebSocket send timed out after {}ms", timeout_duration.as_millis()),
176
+ )),
177
+ Err(WebSocketIoError::Closed) => Err(Error::new(
178
+ magnus::exception::runtime_error(),
179
+ "WebSocket connection closed".to_string(),
180
+ )),
181
+ Err(WebSocketIoError::Other(message)) => Err(Error::new(
182
+ magnus::exception::runtime_error(),
183
+ format!("WebSocket send failed: {}", message),
184
+ )),
185
+ }?;
186
+
187
+ Ok(())
188
+ }
189
+
190
+ /// Send a JSON message
191
+ fn send_json(ruby: &Ruby, this: &Self, obj: Value) -> Result<(), Error> {
192
+ let json_value = ruby_to_json(ruby, obj)?;
193
+ let mut inner = this.inner.borrow_mut();
194
+ let ws = inner
195
+ .as_mut()
196
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
197
+
198
+ let timeout_duration = websocket_timeout();
199
+ let json_value_ref = &json_value;
200
+ let result = crate::call_without_gvl!(
201
+ block_on_send_json,
202
+ args: (
203
+ timeout_duration, Duration,
204
+ ws, &mut WebSocketConnection,
205
+ json_value_ref, &JsonValue
206
+ ),
207
+ return_type: Result<(), WebSocketIoError>
208
+ );
209
+ match result {
210
+ Ok(()) => Ok(()),
211
+ Err(WebSocketIoError::Timeout) => Err(Error::new(
212
+ ruby.exception_runtime_error(),
213
+ format!("WebSocket send timed out after {}ms", timeout_duration.as_millis()),
214
+ )),
215
+ Err(WebSocketIoError::Closed) => Err(Error::new(
216
+ ruby.exception_runtime_error(),
217
+ "WebSocket connection closed".to_string(),
218
+ )),
219
+ Err(WebSocketIoError::Other(message)) => Err(Error::new(
220
+ ruby.exception_runtime_error(),
221
+ format!("WebSocket send failed: {}", message),
222
+ )),
223
+ }?;
224
+
225
+ Ok(())
226
+ }
227
+
228
+ /// Receive a text message
229
+ fn receive_text(&self) -> Result<String, Error> {
230
+ let mut inner = self.inner.borrow_mut();
231
+ let ws = inner
232
+ .as_mut()
233
+ .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
234
+
235
+ let timeout_duration = websocket_timeout();
236
+ let text = crate::call_without_gvl!(
237
+ block_on_receive_text,
238
+ args: (
239
+ timeout_duration, Duration,
240
+ ws, &mut WebSocketConnection
241
+ ),
242
+ return_type: Result<String, WebSocketIoError>
243
+ )
244
+ .map_err(|err| match err {
245
+ WebSocketIoError::Timeout => Error::new(
246
+ magnus::exception::runtime_error(),
247
+ format!("WebSocket receive timed out after {}ms", timeout_duration.as_millis()),
248
+ ),
249
+ WebSocketIoError::Closed => Error::new(
250
+ magnus::exception::runtime_error(),
251
+ "WebSocket connection closed".to_string(),
252
+ ),
253
+ WebSocketIoError::Other(message) => Error::new(
254
+ magnus::exception::runtime_error(),
255
+ format!("WebSocket receive failed: {}", message),
256
+ ),
257
+ })?;
258
+
259
+ Ok(text)
260
+ }
261
+
262
+ /// Receive and parse a JSON message
263
+ fn receive_json(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
264
+ let mut inner = this.inner.borrow_mut();
265
+ let ws = inner
266
+ .as_mut()
267
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
268
+
269
+ let timeout_duration = websocket_timeout();
270
+ let json_value = crate::call_without_gvl!(
271
+ block_on_receive_json,
272
+ args: (
273
+ timeout_duration, Duration,
274
+ ws, &mut WebSocketConnection
275
+ ),
276
+ return_type: Result<JsonValue, WebSocketIoError>
277
+ )
278
+ .map_err(|err| match err {
279
+ WebSocketIoError::Timeout => Error::new(
280
+ ruby.exception_runtime_error(),
281
+ format!("WebSocket receive timed out after {}ms", timeout_duration.as_millis()),
282
+ ),
283
+ WebSocketIoError::Closed => Error::new(
284
+ ruby.exception_runtime_error(),
285
+ "WebSocket connection closed".to_string(),
286
+ ),
287
+ WebSocketIoError::Other(message) => Error::new(
288
+ ruby.exception_runtime_error(),
289
+ format!("WebSocket receive failed: {}", message),
290
+ ),
291
+ })?;
292
+
293
+ json_to_ruby(ruby, &json_value)
294
+ }
295
+
296
+ /// Receive raw bytes
297
+ fn receive_bytes(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
298
+ let mut inner = this.inner.borrow_mut();
299
+ let ws = inner
300
+ .as_mut()
301
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
302
+
303
+ let timeout_duration = websocket_timeout();
304
+ let result = crate::call_without_gvl!(
305
+ block_on_receive_bytes,
306
+ args: (
307
+ timeout_duration, Duration,
308
+ ws, &mut WebSocketConnection
309
+ ),
310
+ return_type: Result<bytes::Bytes, WebSocketIoError>
311
+ );
312
+ let bytes = result.map_err(|err| match err {
313
+ WebSocketIoError::Timeout => Error::new(
314
+ ruby.exception_runtime_error(),
315
+ format!("WebSocket receive timed out after {}ms", timeout_duration.as_millis()),
316
+ ),
317
+ WebSocketIoError::Closed => Error::new(
318
+ ruby.exception_runtime_error(),
319
+ "WebSocket connection closed".to_string(),
320
+ ),
321
+ WebSocketIoError::Other(message) => Error::new(
322
+ ruby.exception_runtime_error(),
323
+ format!("WebSocket receive failed: {}", message),
324
+ ),
325
+ })?;
326
+
327
+ Ok(ruby.str_from_slice(&bytes).as_value())
328
+ }
329
+
330
+ /// Receive a message and return WebSocketMessage
331
+ fn receive_message(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
332
+ let mut inner = this.inner.borrow_mut();
333
+ let ws = inner
334
+ .as_mut()
335
+ .ok_or_else(|| Error::new(ruby.exception_runtime_error(), "WebSocket closed"))?;
336
+
337
+ let timeout_duration = websocket_timeout();
338
+ let result = crate::call_without_gvl!(
339
+ block_on_receive_message,
340
+ args: (
341
+ timeout_duration, Duration,
342
+ ws, &mut WebSocketConnection
343
+ ),
344
+ return_type: Result<WebSocketMessageData, WebSocketIoError>
345
+ );
346
+ let msg = result.map_err(|err| match err {
347
+ WebSocketIoError::Timeout => Error::new(
348
+ ruby.exception_runtime_error(),
349
+ format!("WebSocket receive timed out after {}ms", timeout_duration.as_millis()),
350
+ ),
351
+ WebSocketIoError::Closed => Error::new(
352
+ ruby.exception_runtime_error(),
353
+ "WebSocket connection closed".to_string(),
354
+ ),
355
+ WebSocketIoError::Other(message) => Error::new(
356
+ ruby.exception_runtime_error(),
357
+ format!("WebSocket receive failed: {}", message),
358
+ ),
359
+ })?;
360
+
361
+ let ws_msg = WebSocketMessage::new(msg);
362
+ Ok(ruby.obj_wrap(ws_msg).as_value())
363
+ }
364
+
365
+ /// Close the WebSocket connection
366
+ fn close(&self) -> Result<(), Error> {
367
+ let mut inner = self.inner.borrow_mut();
368
+ let ws = inner
369
+ .take()
370
+ .ok_or_else(|| Error::new(magnus::exception::runtime_error(), "WebSocket closed"))?;
371
+ let result = crate::call_without_gvl!(
372
+ block_on_close,
373
+ args: (ws, WebSocketConnection),
374
+ return_type: Result<(), WebSocketIoError>
375
+ );
376
+ result.map_err(|err| match err {
377
+ WebSocketIoError::Timeout => Error::new(
378
+ magnus::exception::runtime_error(),
379
+ "WebSocket close timed out".to_string(),
380
+ ),
381
+ WebSocketIoError::Closed => Error::new(
382
+ magnus::exception::runtime_error(),
383
+ "WebSocket connection closed".to_string(),
384
+ ),
385
+ WebSocketIoError::Other(message) => Error::new(
386
+ magnus::exception::runtime_error(),
387
+ format!("WebSocket close failed: {}", message),
388
+ ),
389
+ })
390
+ }
391
+ }
392
+
393
+ fn block_on_send_text(
394
+ timeout_duration: Duration,
395
+ ws: &mut WebSocketConnection,
396
+ text: String,
397
+ ) -> Result<(), WebSocketIoError> {
398
+ set_stream_timeouts(ws, timeout_duration)?;
399
+ ws.send_text(text)
400
+ }
401
+
402
+ fn block_on_send_json(
403
+ timeout_duration: Duration,
404
+ ws: &mut WebSocketConnection,
405
+ json_value: &JsonValue,
406
+ ) -> Result<(), WebSocketIoError> {
407
+ set_stream_timeouts(ws, timeout_duration)?;
408
+ ws.send_json(json_value)
409
+ }
410
+
411
+ fn block_on_receive_bytes(
412
+ timeout_duration: Duration,
413
+ ws: &mut WebSocketConnection,
414
+ ) -> Result<bytes::Bytes, WebSocketIoError> {
415
+ set_stream_timeouts(ws, timeout_duration)?;
416
+ ws.receive_bytes()
417
+ }
418
+
419
+ fn block_on_receive_text(timeout_duration: Duration, ws: &mut WebSocketConnection) -> Result<String, WebSocketIoError> {
420
+ set_stream_timeouts(ws, timeout_duration)?;
421
+ ws.receive_text()
422
+ }
423
+
424
+ fn block_on_receive_json(
425
+ timeout_duration: Duration,
426
+ ws: &mut WebSocketConnection,
427
+ ) -> Result<JsonValue, WebSocketIoError> {
428
+ set_stream_timeouts(ws, timeout_duration)?;
429
+ ws.receive_json()
430
+ }
431
+
432
+ fn block_on_receive_message(
433
+ timeout_duration: Duration,
434
+ ws: &mut WebSocketConnection,
435
+ ) -> Result<WebSocketMessageData, WebSocketIoError> {
436
+ set_stream_timeouts(ws, timeout_duration)?;
437
+ ws.receive_message().and_then(WebSocketMessageData::from_tungstenite)
438
+ }
439
+
440
+ fn block_on_close(ws: WebSocketConnection) -> Result<(), WebSocketIoError> {
441
+ ws.close()
442
+ }
443
+
444
+ fn set_stream_timeouts(ws: &mut WebSocketConnection, timeout: Duration) -> Result<(), WebSocketIoError> {
445
+ if let MaybeTlsStream::Plain(stream) = ws.stream.get_mut() {
446
+ stream
447
+ .set_read_timeout(Some(timeout))
448
+ .map_err(|e| WebSocketIoError::Other(e.to_string()))?;
449
+ stream
450
+ .set_write_timeout(Some(timeout))
451
+ .map_err(|e| WebSocketIoError::Other(e.to_string()))?;
452
+ }
453
+ Ok(())
454
+ }
455
+
456
+ fn websocket_timeout() -> Duration {
457
+ const DEFAULT_TIMEOUT_MS: u64 = 30_000;
458
+ let timeout_ms = std::env::var("SPIKARD_RB_WS_TIMEOUT_MS")
459
+ .ok()
460
+ .and_then(|value| value.parse::<u64>().ok())
461
+ .unwrap_or(DEFAULT_TIMEOUT_MS);
462
+ Duration::from_millis(timeout_ms)
463
+ }
464
+
465
+ /// Ruby wrapper for WebSocket messages
466
+ #[allow(dead_code)]
467
+ #[derive(Debug, Clone)]
468
+ pub enum WebSocketMessageData {
469
+ Text(String),
470
+ Binary(Vec<u8>),
471
+ Close(Option<String>),
472
+ Ping(Vec<u8>),
473
+ Pong(Vec<u8>),
474
+ }
475
+
476
+ impl WebSocketMessageData {
477
+ fn from_tungstenite(message: Message) -> Result<Self, WebSocketIoError> {
478
+ match message {
479
+ Message::Text(text) => Ok(Self::Text(text.to_string())),
480
+ Message::Binary(bytes) => Ok(Self::Binary(bytes.to_vec())),
481
+ Message::Close(frame) => Ok(Self::Close(frame.map(|f| f.reason.to_string()))),
482
+ Message::Ping(bytes) => Ok(Self::Ping(bytes.to_vec())),
483
+ Message::Pong(bytes) => Ok(Self::Pong(bytes.to_vec())),
484
+ Message::Frame(_) => Err(WebSocketIoError::Other(
485
+ "Unexpected frame message while reading WebSocket".to_string(),
486
+ )),
487
+ }
488
+ }
489
+ }
490
+
491
+ #[magnus::wrap(class = "Spikard::Native::WebSocketMessage", free_immediately)]
492
+ pub struct WebSocketMessage {
493
+ inner: WebSocketMessageData,
494
+ }
495
+
496
+ impl WebSocketMessage {
497
+ pub fn new(inner: WebSocketMessageData) -> Self {
498
+ Self { inner }
499
+ }
500
+
501
+ /// Get message as text if it's a text message
502
+ fn as_text(&self) -> Result<Option<String>, Error> {
503
+ match &self.inner {
504
+ WebSocketMessageData::Text(text) => Ok(Some(text.clone())),
505
+ _ => Ok(None),
506
+ }
507
+ }
508
+
509
+ /// Get message as JSON if it's a text message containing JSON
510
+ fn as_json(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
511
+ match &this.inner {
512
+ WebSocketMessageData::Text(text) => match serde_json::from_str::<JsonValue>(text) {
513
+ Ok(value) => json_to_ruby(ruby, &value),
514
+ Err(_) => Ok(ruby.qnil().as_value()),
515
+ },
516
+ _ => Ok(ruby.qnil().as_value()),
517
+ }
518
+ }
519
+
520
+ /// Get message as binary if it's a binary message
521
+ fn as_binary(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
522
+ match &this.inner {
523
+ WebSocketMessageData::Binary(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
524
+ WebSocketMessageData::Ping(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
525
+ WebSocketMessageData::Pong(bytes) => Ok(ruby.str_from_slice(bytes).as_value()),
526
+ _ => Ok(ruby.qnil().as_value()),
527
+ }
528
+ }
529
+
530
+ /// Check if this is a close message
531
+ fn is_close(&self) -> bool {
532
+ matches!(self.inner, WebSocketMessageData::Close(_))
533
+ }
534
+ }
535
+
536
+ /// Helper to convert Ruby object to JSON
537
+ fn ruby_to_json(ruby: &Ruby, value: Value) -> Result<JsonValue, Error> {
538
+ let json_module = ruby.class_object().const_get::<_, magnus::RModule>("JSON")?;
539
+ let json_str: String = json_module.funcall("generate", (value,))?;
540
+ serde_json::from_str(&json_str).map_err(|e| {
541
+ Error::new(
542
+ magnus::exception::runtime_error(),
543
+ format!("Failed to parse JSON: {}", e),
544
+ )
545
+ })
546
+ }
547
+
548
+ /// Helper to convert JSON to Ruby object
549
+ fn json_to_ruby(ruby: &Ruby, value: &JsonValue) -> Result<Value, Error> {
550
+ match value {
551
+ JsonValue::Null => Ok(ruby.qnil().as_value()),
552
+ JsonValue::Bool(b) => Ok(if *b {
553
+ ruby.qtrue().as_value()
554
+ } else {
555
+ ruby.qfalse().as_value()
556
+ }),
557
+ JsonValue::Number(n) => {
558
+ if let Some(i) = n.as_i64() {
559
+ Ok(ruby.integer_from_i64(i).as_value())
560
+ } else if let Some(u) = n.as_u64() {
561
+ Ok(ruby.integer_from_i64(u as i64).as_value())
562
+ } else if let Some(f) = n.as_f64() {
563
+ Ok(ruby.float_from_f64(f).as_value())
564
+ } else {
565
+ Ok(ruby.qnil().as_value())
566
+ }
567
+ }
568
+ JsonValue::String(s) => Ok(ruby.str_new(s).as_value()),
569
+ JsonValue::Array(arr) => {
570
+ let ruby_arr = ruby.ary_new();
571
+ for item in arr {
572
+ let ruby_val = json_to_ruby(ruby, item)?;
573
+ ruby_arr.push(ruby_val)?;
574
+ }
575
+ Ok(ruby_arr.as_value())
576
+ }
577
+ JsonValue::Object(obj) => {
578
+ let ruby_hash = ruby.hash_new();
579
+ for (key, val) in obj {
580
+ let ruby_val = json_to_ruby(ruby, val)?;
581
+ ruby_hash.aset(ruby.str_new(key), ruby_val)?;
582
+ }
583
+ Ok(ruby_hash.as_value())
584
+ }
585
+ }
586
+ }
587
+
588
+ /// Initialize WebSocket test client bindings
589
+ pub fn init(ruby: &Ruby, module: &magnus::RModule) -> Result<(), Error> {
590
+ let native_module = module.define_module("Native")?;
591
+
592
+ let ws_conn_class = native_module.define_class("WebSocketTestConnection", ruby.class_object())?;
593
+ ws_conn_class.define_method("send_text", method!(WebSocketTestConnection::send_text, 1))?;
594
+ ws_conn_class.define_method("send_json", method!(WebSocketTestConnection::send_json, 1))?;
595
+ ws_conn_class.define_method("receive_text", method!(WebSocketTestConnection::receive_text, 0))?;
596
+ ws_conn_class.define_method("receive_json", method!(WebSocketTestConnection::receive_json, 0))?;
597
+ ws_conn_class.define_method("receive_bytes", method!(WebSocketTestConnection::receive_bytes, 0))?;
598
+ ws_conn_class.define_method("receive_message", method!(WebSocketTestConnection::receive_message, 0))?;
599
+ ws_conn_class.define_method("close", method!(WebSocketTestConnection::close, 0))?;
600
+
601
+ let ws_msg_class = native_module.define_class("WebSocketMessage", ruby.class_object())?;
602
+ ws_msg_class.define_method("as_text", method!(WebSocketMessage::as_text, 0))?;
603
+ ws_msg_class.define_method("as_json", method!(WebSocketMessage::as_json, 0))?;
604
+ ws_msg_class.define_method("as_binary", method!(WebSocketMessage::as_binary, 0))?;
605
+ ws_msg_class.define_method("is_close", method!(WebSocketMessage::is_close, 0))?;
606
+
607
+ Ok(())
608
+ }