spikard 0.8.3 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -10
  3. data/ext/spikard_rb/Cargo.lock +234 -162
  4. data/ext/spikard_rb/Cargo.toml +2 -2
  5. data/ext/spikard_rb/extconf.rb +4 -3
  6. data/lib/spikard/config.rb +88 -12
  7. data/lib/spikard/testing.rb +3 -1
  8. data/lib/spikard/version.rb +1 -1
  9. data/lib/spikard.rb +11 -0
  10. data/vendor/crates/spikard-bindings-shared/Cargo.toml +3 -6
  11. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +8 -8
  12. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +2 -2
  13. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +4 -4
  14. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +10 -4
  15. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +3 -3
  16. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +10 -5
  17. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +829 -0
  18. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +587 -0
  19. data/vendor/crates/spikard-bindings-shared/src/lib.rs +7 -0
  20. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +11 -11
  21. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +9 -37
  22. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +436 -3
  23. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +944 -0
  24. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +4 -4
  25. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +3 -2
  26. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +13 -13
  27. data/vendor/crates/spikard-bindings-shared/tests/{comprehensive_coverage.rs → full_coverage.rs} +10 -5
  28. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +14 -14
  29. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +669 -0
  30. data/vendor/crates/spikard-core/Cargo.toml +3 -3
  31. data/vendor/crates/spikard-core/src/di/container.rs +1 -1
  32. data/vendor/crates/spikard-core/src/di/factory.rs +2 -2
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +2 -2
  34. data/vendor/crates/spikard-core/src/di/value.rs +1 -1
  35. data/vendor/crates/spikard-core/src/http.rs +75 -0
  36. data/vendor/crates/spikard-core/src/lifecycle.rs +43 -43
  37. data/vendor/crates/spikard-core/src/parameters.rs +14 -19
  38. data/vendor/crates/spikard-core/src/problem.rs +1 -1
  39. data/vendor/crates/spikard-core/src/request_data.rs +7 -16
  40. data/vendor/crates/spikard-core/src/router.rs +6 -0
  41. data/vendor/crates/spikard-core/src/schema_registry.rs +2 -3
  42. data/vendor/crates/spikard-core/src/type_hints.rs +3 -2
  43. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +1 -1
  44. data/vendor/crates/spikard-core/src/validation/mod.rs +1 -1
  45. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +1 -1
  46. data/vendor/crates/spikard-core/tests/error_mapper.rs +2 -2
  47. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +1 -1
  48. data/vendor/crates/spikard-core/tests/parameters_full.rs +1 -1
  49. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +1 -1
  50. data/vendor/crates/spikard-core/tests/validation_coverage.rs +4 -4
  51. data/vendor/crates/spikard-http/Cargo.toml +4 -2
  52. data/vendor/crates/spikard-http/src/cors.rs +32 -11
  53. data/vendor/crates/spikard-http/src/di_handler.rs +12 -8
  54. data/vendor/crates/spikard-http/src/grpc/framing.rs +469 -0
  55. data/vendor/crates/spikard-http/src/grpc/handler.rs +887 -25
  56. data/vendor/crates/spikard-http/src/grpc/mod.rs +114 -22
  57. data/vendor/crates/spikard-http/src/grpc/service.rs +232 -2
  58. data/vendor/crates/spikard-http/src/grpc/streaming.rs +80 -2
  59. data/vendor/crates/spikard-http/src/handler_trait.rs +204 -27
  60. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +15 -15
  61. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +2 -2
  62. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2 -2
  63. data/vendor/crates/spikard-http/src/lib.rs +1 -1
  64. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +2 -2
  65. data/vendor/crates/spikard-http/src/lifecycle.rs +4 -4
  66. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +2 -0
  67. data/vendor/crates/spikard-http/src/server/fast_router.rs +186 -0
  68. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +324 -23
  69. data/vendor/crates/spikard-http/src/server/handler.rs +33 -22
  70. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +21 -2
  71. data/vendor/crates/spikard-http/src/server/mod.rs +125 -20
  72. data/vendor/crates/spikard-http/src/server/request_extraction.rs +126 -44
  73. data/vendor/crates/spikard-http/src/server/routing_factory.rs +80 -69
  74. data/vendor/crates/spikard-http/tests/common/handlers.rs +2 -2
  75. data/vendor/crates/spikard-http/tests/common/test_builders.rs +12 -12
  76. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +2 -2
  77. data/vendor/crates/spikard-http/tests/di_integration.rs +6 -6
  78. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +430 -0
  79. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +738 -0
  80. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +13 -9
  81. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +974 -0
  82. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +2 -2
  83. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +4 -4
  84. data/vendor/crates/spikard-http/tests/server_config_builder.rs +2 -2
  85. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -0
  86. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +140 -0
  87. data/vendor/crates/spikard-rb/Cargo.toml +3 -1
  88. data/vendor/crates/spikard-rb/src/conversion.rs +138 -4
  89. data/vendor/crates/spikard-rb/src/grpc/handler.rs +706 -229
  90. data/vendor/crates/spikard-rb/src/grpc/mod.rs +6 -2
  91. data/vendor/crates/spikard-rb/src/gvl.rs +2 -2
  92. data/vendor/crates/spikard-rb/src/handler.rs +169 -91
  93. data/vendor/crates/spikard-rb/src/lib.rs +444 -62
  94. data/vendor/crates/spikard-rb/src/lifecycle.rs +29 -1
  95. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +108 -43
  96. data/vendor/crates/spikard-rb/src/request.rs +117 -20
  97. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +52 -25
  98. data/vendor/crates/spikard-rb/src/server.rs +23 -14
  99. data/vendor/crates/spikard-rb/src/testing/client.rs +5 -4
  100. data/vendor/crates/spikard-rb/src/testing/sse.rs +1 -36
  101. data/vendor/crates/spikard-rb/src/testing/websocket.rs +3 -38
  102. data/vendor/crates/spikard-rb/src/websocket.rs +32 -23
  103. data/vendor/crates/spikard-rb-macros/Cargo.toml +1 -1
  104. metadata +14 -4
  105. data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +0 -5
  106. data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +0 -2
@@ -1,30 +1,40 @@
1
- //! Ruby gRPC handler implementation using Magnus FFI
1
+ //! Ruby gRPC handler implementation
2
2
  //!
3
- //! This module provides a bridge between Ruby code implementing gRPC handlers
4
- //! and Spikard's Rust-based gRPC runtime. It handles serialization/deserialization
5
- //! of protobuf messages as binary strings.
3
+ //! This module provides Magnus bindings for gRPC request/response handling,
4
+ //! enabling Ruby code to implement gRPC service handlers with full streaming support.
5
+
6
+ // Allow dead code - these types are exported but not yet integrated into the main Ruby API
7
+ #![allow(dead_code)]
6
8
 
7
9
  use bytes::Bytes;
10
+ use futures::stream::StreamExt;
8
11
  use magnus::prelude::*;
9
12
  use magnus::value::{InnerValue, Opaque};
10
- use magnus::{Error, RHash, RString, Ruby, Symbol, TryConvert, Value, gc::Marker};
11
- use spikard_bindings_shared::grpc_metadata::{extract_metadata_to_hashmap, hashmap_to_metadata};
13
+ use magnus::{Error, Module, RArray, RHash, RString, Ruby, Value};
14
+ use spikard_http::grpc::streaming::{MessageStream, StreamingRequest};
12
15
  use spikard_http::grpc::{GrpcHandler, GrpcHandlerResult, GrpcRequestData, GrpcResponseData};
13
16
  use std::cell::RefCell;
14
17
  use std::collections::HashMap;
15
18
  use std::future::Future;
16
- use std::panic::AssertUnwindSafe;
17
19
  use std::pin::Pin;
18
20
  use std::sync::Arc;
19
-
20
- use crate::gvl::with_gvl;
21
-
22
- /// Ruby-facing gRPC request object
21
+ use std::time::Duration;
22
+ use tonic::metadata::MetadataMap;
23
+
24
+ /// DOS protection limits
25
+ const MAX_METADATA_ENTRIES: usize = 128;
26
+ const MAX_METADATA_KEY_SIZE: usize = 1024;
27
+ const MAX_METADATA_VALUE_SIZE: usize = 8192;
28
+ const MAX_PAYLOAD_BYTES: usize = 100 * 1024 * 1024; // 100MB
29
+ const MAX_STREAM_MESSAGES: usize = 10_000;
30
+ const MAX_STREAM_TOTAL_BYTES: usize = 500 * 1024 * 1024; // 500MB total for streams
31
+ const HANDLER_TIMEOUT_SECS: u64 = 30;
32
+
33
+ /// Ruby gRPC request class
23
34
  ///
24
- /// This struct is exposed to Ruby code and contains the parsed components
25
- /// of a gRPC request. The payload is provided as a binary string that Ruby
26
- /// code can deserialize using the google-protobuf gem.
27
- #[derive(Debug, Clone)]
35
+ /// Represents a gRPC request passed to Ruby handlers.
36
+ /// Contains service name, method name, serialized payload, and metadata.
37
+ #[derive(Clone)]
28
38
  #[magnus::wrap(class = "Spikard::Grpc::Request", free_immediately)]
29
39
  pub struct RubyGrpcRequest {
30
40
  service_name: String,
@@ -34,47 +44,61 @@ pub struct RubyGrpcRequest {
34
44
  }
35
45
 
36
46
  impl RubyGrpcRequest {
37
- /// Create a new RubyGrpcRequest from GrpcRequestData
38
- fn from_grpc_request(request: GrpcRequestData) -> Self {
39
- let metadata = extract_metadata_to_hashmap(&request.metadata, true);
47
+ /// Create a new Ruby gRPC request
48
+ pub fn new(service_name: String, method_name: String, payload: Vec<u8>, metadata: HashMap<String, String>) -> Self {
40
49
  Self {
41
- service_name: request.service_name,
42
- method_name: request.method_name,
43
- payload: request.payload.to_vec(),
50
+ service_name,
51
+ method_name,
52
+ payload,
44
53
  metadata,
45
54
  }
46
55
  }
47
56
 
48
- /// Get the service name
49
- fn service_name(&self) -> &str {
50
- &self.service_name
57
+ /// Get service name
58
+ fn rb_service_name(&self) -> String {
59
+ self.service_name.clone()
51
60
  }
52
61
 
53
- /// Get the method name
54
- fn method_name(&self) -> &str {
55
- &self.method_name
62
+ /// Get method name
63
+ fn rb_method_name(&self) -> String {
64
+ self.method_name.clone()
56
65
  }
57
66
 
58
- /// Get the payload as a binary string
59
- fn payload(ruby: &Ruby, rb_self: &Self) -> Value {
60
- ruby.str_from_slice(&rb_self.payload).as_value()
67
+ /// Get payload as Ruby string (binary)
68
+ fn rb_payload(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
69
+ Ok(ruby.str_from_slice(&this.payload).as_value())
61
70
  }
62
71
 
63
- /// Get metadata as a Ruby hash
64
- fn metadata(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
65
- let hash = ruby.hash_new();
66
- for (key, value) in &rb_self.metadata {
72
+ /// Get metadata as Ruby hash
73
+ fn rb_metadata(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
74
+ let hash = ruby.hash_new_capa(this.metadata.len());
75
+ for (key, value) in &this.metadata {
67
76
  hash.aset(ruby.str_new(key), ruby.str_new(value))?;
68
77
  }
69
78
  Ok(hash.as_value())
70
79
  }
80
+
81
+ /// Get metadata value by key
82
+ fn rb_get_metadata(&self, key: String) -> Option<String> {
83
+ self.metadata.get(&key).cloned()
84
+ }
85
+
86
+ /// String representation for debugging
87
+ fn rb_inspect(&self) -> String {
88
+ format!(
89
+ "#<Spikard::Grpc::Request service_name={:?} method_name={:?} payload_size={}>",
90
+ self.service_name,
91
+ self.method_name,
92
+ self.payload.len()
93
+ )
94
+ }
71
95
  }
72
96
 
73
- /// Ruby-facing gRPC response object
97
+ /// Ruby gRPC response class
74
98
  ///
75
- /// Ruby code creates instances of this class to return gRPC responses.
76
- /// The payload should be a binary string containing the serialized protobuf message.
77
- #[derive(Debug, Clone, Default)]
99
+ /// Represents a gRPC response returned from Ruby handlers.
100
+ /// Contains serialized payload and optional metadata.
101
+ #[derive(Clone)]
78
102
  #[magnus::wrap(class = "Spikard::Grpc::Response", free_immediately)]
79
103
  pub struct RubyGrpcResponse {
80
104
  payload: RefCell<Vec<u8>>,
@@ -82,186 +106,678 @@ pub struct RubyGrpcResponse {
82
106
  }
83
107
 
84
108
  impl RubyGrpcResponse {
85
- /// Initialize the response with a payload (called by Ruby's new)
86
- fn initialize(&self, args: &[Value]) -> Result<(), Error> {
87
- // Handle both positional and keyword arguments
88
- let payload_value = if args.is_empty() {
89
- return Err(Error::new(magnus::exception::arg_error(), "missing keyword: payload"));
90
- } else if args.len() == 1 {
91
- // Check if it's a hash (keyword args) or a string (positional arg)
92
- if let Ok(hash) = RHash::try_convert(args[0]) {
93
- // Keyword arguments: { payload: "data" }
94
- hash.get(Symbol::new("payload"))
95
- .ok_or_else(|| Error::new(magnus::exception::arg_error(), "missing keyword: payload"))?
96
- } else {
97
- // Positional argument: "data"
98
- args[0]
109
+ /// Create a new Ruby gRPC response from Ruby
110
+ fn rb_new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
111
+ let (payload, metadata) = match args {
112
+ [single] => {
113
+ if let Ok(hash) = RHash::try_convert(*single) {
114
+ let payload_value = get_kw(hash, ruby, "payload")
115
+ .ok_or_else(|| Error::new(magnus::exception::arg_error(), "Response.new requires a payload"))?;
116
+
117
+ // Validate that payload is not nil
118
+ if payload_value.is_nil() {
119
+ return Err(Error::new(magnus::exception::arg_error(), "payload cannot be nil"));
120
+ }
121
+
122
+ let payload = RString::try_convert(payload_value)
123
+ .map_err(|_| Error::new(magnus::exception::arg_error(), "payload must be a String"))?;
124
+ let metadata_value = get_kw(hash, ruby, "metadata");
125
+ let metadata = match metadata_value {
126
+ Some(value) if !value.is_nil() => Some(RHash::try_convert(value)?),
127
+ _ => None,
128
+ };
129
+ (payload, metadata)
130
+ } else {
131
+ // Validate that single is not nil
132
+ if single.is_nil() {
133
+ return Err(Error::new(magnus::exception::arg_error(), "payload cannot be nil"));
134
+ }
135
+
136
+ let payload = RString::try_convert(*single)
137
+ .map_err(|_| Error::new(magnus::exception::arg_error(), "payload must be a String"))?;
138
+ (payload, None)
139
+ }
140
+ }
141
+ [payload_value, metadata_value] => {
142
+ // Validate that payload is not nil
143
+ if payload_value.is_nil() {
144
+ return Err(Error::new(magnus::exception::arg_error(), "payload cannot be nil"));
145
+ }
146
+
147
+ let payload = RString::try_convert(*payload_value)
148
+ .map_err(|_| Error::new(magnus::exception::arg_error(), "payload must be a String"))?;
149
+ let metadata = if metadata_value.is_nil() {
150
+ None
151
+ } else {
152
+ Some(RHash::try_convert(*metadata_value)?)
153
+ };
154
+ (payload, metadata)
155
+ }
156
+ _ => {
157
+ return Err(Error::new(
158
+ magnus::exception::arg_error(),
159
+ "Response.new expects payload or payload with metadata",
160
+ ));
99
161
  }
162
+ };
163
+ // SAFETY: RString::as_slice() is safe when the Ruby VM is active (which it is here since
164
+ // we're in a Ruby method call). The slice is immediately copied to owned Vec<u8>.
165
+ let payload_bytes = unsafe { payload.as_slice().to_vec() };
166
+
167
+ if payload_bytes.len() > MAX_PAYLOAD_BYTES {
168
+ return Err(Error::new(
169
+ magnus::exception::arg_error(),
170
+ format!(
171
+ "Payload size {} exceeds maximum {}",
172
+ payload_bytes.len(),
173
+ MAX_PAYLOAD_BYTES
174
+ ),
175
+ ));
176
+ }
177
+
178
+ let meta = if let Some(hash) = metadata {
179
+ ruby_hash_to_string_map(&hash)?
100
180
  } else {
101
- return Err(Error::new(magnus::exception::arg_error(), "wrong number of arguments"));
181
+ HashMap::new()
102
182
  };
103
183
 
104
- let payload_str = RString::try_convert(payload_value)
105
- .map_err(|_| Error::new(magnus::exception::arg_error(), "payload must be a String (binary)"))?;
184
+ if meta.len() > MAX_METADATA_ENTRIES {
185
+ return Err(Error::new(
186
+ magnus::exception::arg_error(),
187
+ format!(
188
+ "Metadata entries {} exceeds maximum {}",
189
+ meta.len(),
190
+ MAX_METADATA_ENTRIES
191
+ ),
192
+ ));
193
+ }
106
194
 
107
- let payload_bytes = unsafe { payload_str.as_slice() }.to_vec();
195
+ Ok(Self {
196
+ payload: RefCell::new(payload_bytes),
197
+ metadata: RefCell::new(meta),
198
+ })
199
+ }
200
+
201
+ /// Get payload
202
+ fn rb_payload(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
203
+ let payload = this.payload.borrow();
204
+ Ok(ruby.str_from_slice(&payload).as_value())
205
+ }
108
206
 
109
- *self.payload.borrow_mut() = payload_bytes;
110
- *self.metadata.borrow_mut() = HashMap::new();
207
+ /// Set payload
208
+ fn rb_set_payload(&self, payload: RString) -> Result<(), Error> {
209
+ // SAFETY: RString::as_slice() is safe when Ruby VM is active.
210
+ let bytes = unsafe { payload.as_slice().to_vec() };
211
+ if bytes.len() > MAX_PAYLOAD_BYTES {
212
+ return Err(Error::new(
213
+ magnus::exception::arg_error(),
214
+ format!("Payload size {} exceeds maximum {}", bytes.len(), MAX_PAYLOAD_BYTES),
215
+ ));
216
+ }
217
+ *self.payload.borrow_mut() = bytes;
111
218
  Ok(())
112
219
  }
113
220
 
114
- /// Set metadata on the response
115
- fn set_metadata(&self, metadata: Value) -> Result<(), Error> {
221
+ /// Get metadata
222
+ fn rb_metadata(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
223
+ let meta = this.metadata.borrow();
224
+ let hash = ruby.hash_new_capa(meta.len());
225
+ for (key, value) in meta.iter() {
226
+ hash.aset(ruby.str_new(key), ruby.str_new(value))?;
227
+ }
228
+ Ok(hash.as_value())
229
+ }
230
+
231
+ /// Set metadata
232
+ fn rb_set_metadata(&self, metadata: Value) -> Result<(), Error> {
233
+ // Handle nil metadata - treat as empty hash
116
234
  if metadata.is_nil() {
235
+ *self.metadata.borrow_mut() = HashMap::new();
117
236
  return Ok(());
118
237
  }
119
238
 
120
239
  let hash = RHash::try_convert(metadata)?;
121
- let metadata_map = hash.to_hash_map::<String, String>()?;
122
- *self.metadata.borrow_mut() = metadata_map;
240
+ let meta = ruby_hash_to_string_map(&hash)?;
241
+ if meta.len() > MAX_METADATA_ENTRIES {
242
+ return Err(Error::new(
243
+ magnus::exception::arg_error(),
244
+ format!(
245
+ "Metadata entries {} exceeds maximum {}",
246
+ meta.len(),
247
+ MAX_METADATA_ENTRIES
248
+ ),
249
+ ));
250
+ }
251
+ *self.metadata.borrow_mut() = meta;
123
252
  Ok(())
124
253
  }
125
254
 
126
- /// Get the payload
127
- fn payload(ruby: &Ruby, rb_self: &Self) -> Value {
128
- ruby.str_from_slice(&rb_self.payload.borrow()).as_value()
255
+ /// String representation
256
+ fn rb_inspect(&self) -> String {
257
+ format!(
258
+ "#<Spikard::Grpc::Response payload_size={}>",
259
+ self.payload.borrow().len()
260
+ )
129
261
  }
262
+ }
130
263
 
131
- /// Get metadata as a Ruby hash
132
- fn get_metadata(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
133
- let hash = ruby.hash_new();
134
- for (key, value) in rb_self.metadata.borrow().iter() {
135
- hash.aset(ruby.str_new(key), ruby.str_new(value))?;
264
+ fn get_kw(hash: RHash, ruby: &Ruby, key: &str) -> Option<Value> {
265
+ hash.get(ruby.to_symbol(key)).or_else(|| hash.get(ruby.str_new(key)))
266
+ }
267
+
268
+ /// Convert Ruby hash to HashMap<String, String>
269
+ fn ruby_hash_to_string_map(hash: &RHash) -> Result<HashMap<String, String>, Error> {
270
+ let mut map = HashMap::new();
271
+ hash.foreach(|key: Value, value: Value| {
272
+ let key_str = String::try_convert(key)?;
273
+ if key_str.len() > MAX_METADATA_KEY_SIZE {
274
+ return Err(Error::new(
275
+ magnus::exception::arg_error(),
276
+ format!("Metadata key exceeds maximum size {}", MAX_METADATA_KEY_SIZE),
277
+ ));
136
278
  }
137
- Ok(hash.as_value())
138
- }
139
279
 
140
- /// Convert to GrpcResponseData
141
- fn into_grpc_response(self) -> Result<GrpcResponseData, String> {
142
- let metadata = hashmap_to_metadata(&self.metadata.borrow())?;
280
+ let value_str = String::try_convert(value)?;
281
+ if value_str.len() > MAX_METADATA_VALUE_SIZE {
282
+ return Err(Error::new(
283
+ magnus::exception::arg_error(),
284
+ format!("Metadata value exceeds maximum size {}", MAX_METADATA_VALUE_SIZE),
285
+ ));
286
+ }
143
287
 
144
- Ok(GrpcResponseData {
145
- payload: Bytes::from(self.payload.borrow().clone()),
146
- metadata,
147
- })
288
+ map.insert(key_str, value_str);
289
+ Ok(magnus::r_hash::ForEach::Continue)
290
+ })?;
291
+ Ok(map)
292
+ }
293
+
294
+ /// Convert MetadataMap to HashMap<String, String>
295
+ fn metadata_map_to_hashmap(metadata: &MetadataMap) -> HashMap<String, String> {
296
+ let mut map = HashMap::new();
297
+ for key_value in metadata.iter() {
298
+ if let tonic::metadata::KeyAndValueRef::Ascii(key, value) = key_value
299
+ && let Ok(value_str) = value.to_str()
300
+ {
301
+ map.insert(key.as_str().to_string(), value_str.to_string());
302
+ }
148
303
  }
304
+ map
149
305
  }
150
306
 
151
- /// Ruby gRPC handler wrapper
307
+ /// Convert HashMap to MetadataMap
308
+ fn hashmap_to_metadata_map(map: &HashMap<String, String>) -> Result<MetadataMap, tonic::Status> {
309
+ let mut metadata = MetadataMap::new();
310
+ for (key, value) in map {
311
+ let metadata_key = key
312
+ .parse::<tonic::metadata::MetadataKey<tonic::metadata::Ascii>>()
313
+ .map_err(|e| tonic::Status::invalid_argument(format!("Invalid metadata key '{}': {}", key, e)))?;
314
+ let metadata_value = value
315
+ .parse::<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>()
316
+ .map_err(|e| tonic::Status::invalid_argument(format!("Invalid metadata value for key '{}': {}", key, e)))?;
317
+ metadata.insert(metadata_key, metadata_value);
318
+ }
319
+ Ok(metadata)
320
+ }
321
+
322
+ /// Convert Ruby exception to gRPC status
152
323
  ///
153
- /// Wraps a Ruby handler object and implements the GrpcHandler trait,
154
- /// allowing Ruby code to handle gRPC requests.
155
- #[derive(Clone)]
156
- pub struct RubyGrpcHandler {
157
- inner: Arc<RubyGrpcHandlerInner>,
324
+ /// Maps Ruby exceptions to appropriate gRPC status codes without exposing
325
+ /// internal implementation details to clients.
326
+ fn ruby_error_to_grpc_status(err: Error) -> tonic::Status {
327
+ let msg = err.to_string();
328
+
329
+ // Log the full error for debugging but return sanitized messages to clients
330
+ tracing::error!(error = %msg, "Ruby handler error");
331
+
332
+ // Check common Ruby exception types by message patterns
333
+ // Return sanitized error messages to avoid leaking internal details
334
+ if msg.contains("ArgumentError") || msg.contains("invalid") {
335
+ tonic::Status::invalid_argument("Invalid argument")
336
+ } else if msg.contains("PermissionError") || msg.contains("permission") {
337
+ tonic::Status::permission_denied("Permission denied")
338
+ } else if msg.contains("NotImplementedError") || msg.contains("not implemented") {
339
+ tonic::Status::unimplemented("Method not implemented")
340
+ } else if msg.contains("Timeout") || msg.contains("timeout") {
341
+ tonic::Status::deadline_exceeded("Request timeout")
342
+ } else if msg.contains("NotFoundError") || msg.contains("not found") {
343
+ tonic::Status::not_found("Resource not found")
344
+ } else {
345
+ tonic::Status::internal("Handler error")
346
+ }
158
347
  }
159
348
 
160
- struct RubyGrpcHandlerInner {
349
+ /// Extract RubyGrpcResponse from a Ruby value
350
+ fn extract_ruby_response(response_value: Value) -> Result<(Vec<u8>, HashMap<String, String>), Error> {
351
+ // Try to extract as RubyGrpcResponse
352
+ if let Ok(response) = <&RubyGrpcResponse>::try_convert(response_value) {
353
+ let payload = response.payload.borrow().clone();
354
+ let metadata = response.metadata.borrow().clone();
355
+ return Ok((payload, metadata));
356
+ }
357
+
358
+ // Fallback: try to extract as raw bytes (RString)
359
+ if let Ok(bytes) = RString::try_convert(response_value) {
360
+ // SAFETY: RString::as_slice() is safe when Ruby VM is active.
361
+ let payload = unsafe { bytes.as_slice().to_vec() };
362
+ return Ok((payload, HashMap::new()));
363
+ }
364
+
365
+ Err(Error::new(
366
+ magnus::exception::type_error(),
367
+ "Response must be a Spikard::Grpc::Response or binary string",
368
+ ))
369
+ }
370
+
371
+ /// Ruby gRPC handler that bridges Ruby code to Rust's GrpcHandler trait
372
+ pub struct RubyGrpcHandler {
373
+ /// Ruby handler proc/callable stored safely for cross-thread access
161
374
  handler: Opaque<Value>,
162
- service_name: String,
375
+ /// Fully qualified service name
376
+ service_name: Arc<str>,
163
377
  }
164
378
 
165
379
  impl RubyGrpcHandler {
166
- /// Create a new RubyGrpcHandler
167
- ///
168
- /// # Arguments
169
- ///
170
- /// * `handler` - A Ruby object that responds to `handle_request(request)`
171
- /// * `service_name` - The fully qualified service name (e.g., "mypackage.MyService")
172
- #[allow(dead_code)]
380
+ /// Create a new Ruby gRPC handler
173
381
  pub fn new(handler: Value, service_name: String) -> Self {
174
382
  Self {
175
- inner: Arc::new(RubyGrpcHandlerInner {
176
- handler: Opaque::from(handler),
177
- service_name,
178
- }),
383
+ handler: Opaque::from(handler),
384
+ service_name: Arc::from(service_name.as_str()),
179
385
  }
180
386
  }
181
387
 
182
- /// Required by Ruby GC; invoked through the magnus mark hook.
183
- #[allow(dead_code)]
184
- pub fn mark(&self, marker: &Marker) {
185
- if let Ok(ruby) = Ruby::get() {
186
- let handler_val = self.inner.handler.get_inner_with(&ruby);
187
- marker.mark(handler_val);
388
+ /// Create a RubyGrpcRequest from GrpcRequestData
389
+ fn create_ruby_request(_ruby: &Ruby, request: &GrpcRequestData) -> Result<RubyGrpcRequest, Error> {
390
+ let metadata = metadata_map_to_hashmap(&request.metadata);
391
+ Ok(RubyGrpcRequest::new(
392
+ request.service_name.clone(),
393
+ request.method_name.clone(),
394
+ request.payload.to_vec(),
395
+ metadata,
396
+ ))
397
+ }
398
+
399
+ /// Call the Ruby handler with a request
400
+ fn call_ruby_handler(ruby: &Ruby, handler: Value, request: RubyGrpcRequest) -> Result<Value, Error> {
401
+ let request_value = ruby.wrap(request);
402
+
403
+ // Check if handler is callable or has handle_request method
404
+ if handler.respond_to("call", false)? {
405
+ handler.funcall("call", (request_value,))
406
+ } else if handler.respond_to("handle_request", false)? {
407
+ handler.funcall("handle_request", (request_value,))
408
+ } else {
409
+ Err(Error::new(
410
+ magnus::exception::type_error(),
411
+ "Handler must be callable (respond to #call) or have a #handle_request method",
412
+ ))
188
413
  }
189
414
  }
190
415
 
191
- /// Handle a gRPC request by calling into Ruby
192
- fn handle_request(&self, request: GrpcRequestData) -> GrpcHandlerResult {
193
- with_gvl(|| {
194
- let result = std::panic::catch_unwind(AssertUnwindSafe(|| self.handle_request_inner(request)));
195
- match result {
196
- Ok(res) => res,
197
- Err(_) => Err(tonic::Status::internal(
198
- "Unexpected panic while executing Ruby gRPC handler",
199
- )),
200
- }
201
- })
416
+ /// Call the Ruby handler for server streaming
417
+ fn call_ruby_server_stream(ruby: &Ruby, handler: Value, request: RubyGrpcRequest) -> Result<Value, Error> {
418
+ let request_value = ruby.wrap(request);
419
+
420
+ // Check for handle_server_stream method first, then fall back to call
421
+ if handler.respond_to("handle_server_stream", false)? {
422
+ handler.funcall("handle_server_stream", (request_value,))
423
+ } else if handler.respond_to("call", false)? {
424
+ handler.funcall("call", (request_value,))
425
+ } else {
426
+ Err(Error::new(
427
+ magnus::exception::type_error(),
428
+ "Handler must have #handle_server_stream or #call method for server streaming",
429
+ ))
430
+ }
202
431
  }
203
432
 
204
- fn handle_request_inner(&self, request: GrpcRequestData) -> GrpcHandlerResult {
205
- let ruby =
206
- Ruby::get().map_err(|_| tonic::Status::internal("Ruby VM unavailable while invoking gRPC handler"))?;
433
+ /// Call the Ruby handler for client streaming
434
+ fn call_ruby_client_stream(ruby: &Ruby, handler: Value, messages: Vec<Vec<u8>>) -> Result<Value, Error> {
435
+ // Convert messages to Ruby array of binary strings
436
+ let array = ruby.ary_new_capa(messages.len());
437
+ for msg in messages {
438
+ array.push(ruby.str_from_slice(&msg))?;
439
+ }
207
440
 
208
- // Convert request to Ruby object
209
- let ruby_request = RubyGrpcRequest::from_grpc_request(request);
210
- let request_value = ruby.obj_wrap(ruby_request).as_value();
441
+ // Check for handle_client_stream method first, then fall back to call
442
+ if handler.respond_to("handle_client_stream", false)? {
443
+ handler.funcall("handle_client_stream", (array,))
444
+ } else if handler.respond_to("call", false)? {
445
+ handler.funcall("call", (array,))
446
+ } else {
447
+ Err(Error::new(
448
+ magnus::exception::type_error(),
449
+ "Handler must have #handle_client_stream or #call method for client streaming",
450
+ ))
451
+ }
452
+ }
211
453
 
212
- // Call Ruby handler
213
- let handler_value = self.inner.handler.get_inner_with(&ruby);
214
- let response_value = handler_value
215
- .funcall::<_, _, Value>("handle_request", (request_value,))
216
- .map_err(|err| tonic::Status::internal(format!("Ruby gRPC handler failed: {}", err)))?;
454
+ /// Call the Ruby handler for bidirectional streaming
455
+ fn call_ruby_bidi_stream(ruby: &Ruby, handler: Value, messages: Vec<Vec<u8>>) -> Result<Value, Error> {
456
+ // Convert messages to Ruby array of binary strings
457
+ let array = ruby.ary_new_capa(messages.len());
458
+ for msg in messages {
459
+ array.push(ruby.str_from_slice(&msg))?;
460
+ }
217
461
 
218
- // Convert Ruby response to GrpcResponseData
219
- let ruby_response = <&RubyGrpcResponse>::try_convert(response_value).map_err(|err| {
220
- tonic::Status::internal(format!(
221
- "Handler must return Spikard::Grpc::Response, got error: {}",
222
- err
462
+ // Check for handle_bidi_stream method first, then fall back to call
463
+ if handler.respond_to("handle_bidi_stream", false)? {
464
+ handler.funcall("handle_bidi_stream", (array,))
465
+ } else if handler.respond_to("call", false)? {
466
+ handler.funcall("call", (array,))
467
+ } else {
468
+ Err(Error::new(
469
+ magnus::exception::type_error(),
470
+ "Handler must have #handle_bidi_stream or #call method for bidirectional streaming",
223
471
  ))
224
- })?;
472
+ }
473
+ }
225
474
 
226
- ruby_response
227
- .clone()
228
- .into_grpc_response()
229
- .map_err(|err| tonic::Status::internal(format!("Failed to build gRPC response: {}", err)))
475
+ /// Collect messages from Ruby Enumerator/Array into Vec<Bytes>
476
+ fn collect_ruby_stream(_ruby: &Ruby, value: Value) -> Result<Vec<Bytes>, Error> {
477
+ let mut messages = Vec::new();
478
+
479
+ // Check if it's an Array
480
+ if let Ok(array) = RArray::try_convert(value) {
481
+ for idx in 0..array.len() {
482
+ if messages.len() >= MAX_STREAM_MESSAGES {
483
+ return Err(Error::new(
484
+ magnus::exception::runtime_error(),
485
+ format!("Stream exceeded maximum {} messages", MAX_STREAM_MESSAGES),
486
+ ));
487
+ }
488
+
489
+ let item: Value = array.entry(idx as isize)?;
490
+ let bytes = Self::extract_message_bytes(item)?;
491
+ messages.push(bytes);
492
+ }
493
+ return Ok(messages);
494
+ }
495
+
496
+ // Check if it's an Enumerator (responds to each)
497
+ if value.respond_to("each", false)? {
498
+ // Use to_a to collect enumerable
499
+ let array: RArray = value.funcall("to_a", ())?;
500
+ for idx in 0..array.len() {
501
+ if messages.len() >= MAX_STREAM_MESSAGES {
502
+ return Err(Error::new(
503
+ magnus::exception::runtime_error(),
504
+ format!("Stream exceeded maximum {} messages", MAX_STREAM_MESSAGES),
505
+ ));
506
+ }
507
+
508
+ let item: Value = array.entry(idx as isize)?;
509
+ let bytes = Self::extract_message_bytes(item)?;
510
+ messages.push(bytes);
511
+ }
512
+ return Ok(messages);
513
+ }
514
+
515
+ Err(Error::new(
516
+ magnus::exception::type_error(),
517
+ "Stream must be an Array or Enumerable",
518
+ ))
519
+ }
520
+
521
+ /// Extract bytes from a Ruby value (RubyGrpcResponse or RString)
522
+ fn extract_message_bytes(value: Value) -> Result<Bytes, Error> {
523
+ // Try RubyGrpcResponse
524
+ if let Ok(response) = <&RubyGrpcResponse>::try_convert(value) {
525
+ let payload = response.payload.borrow().clone();
526
+ return Ok(Bytes::from(payload));
527
+ }
528
+
529
+ // Try RString
530
+ if let Ok(string) = RString::try_convert(value) {
531
+ // SAFETY: RString::as_slice() is safe when Ruby VM is active.
532
+ let bytes = unsafe { string.as_slice().to_vec() };
533
+ return Ok(Bytes::from(bytes));
534
+ }
535
+
536
+ Err(Error::new(
537
+ magnus::exception::type_error(),
538
+ "Stream message must be a Spikard::Grpc::Response or binary string",
539
+ ))
230
540
  }
231
541
  }
232
542
 
233
543
  impl GrpcHandler for RubyGrpcHandler {
234
544
  fn call(&self, request: GrpcRequestData) -> Pin<Box<dyn Future<Output = GrpcHandlerResult> + Send>> {
235
- let handler = self.clone();
236
- Box::pin(async move { handler.handle_request(request) })
545
+ let handler = self.handler.clone();
546
+
547
+ Box::pin(async move {
548
+ // Execute Ruby handler in a blocking context
549
+ tokio::task::spawn_blocking(move || {
550
+ let ruby = match Ruby::get() {
551
+ Ok(r) => r,
552
+ Err(e) => return Err(tonic::Status::internal(format!("Failed to get Ruby VM: {}", e))),
553
+ };
554
+
555
+ let handler_value = handler.get_inner_with(&ruby);
556
+
557
+ // Create Ruby request
558
+ let ruby_request = Self::create_ruby_request(&ruby, &request).map_err(ruby_error_to_grpc_status)?;
559
+
560
+ // Call handler
561
+ let response_value =
562
+ Self::call_ruby_handler(&ruby, handler_value, ruby_request).map_err(ruby_error_to_grpc_status)?;
563
+
564
+ // Extract response
565
+ let (payload, metadata_map) =
566
+ extract_ruby_response(response_value).map_err(ruby_error_to_grpc_status)?;
567
+
568
+ let metadata = hashmap_to_metadata_map(&metadata_map)?;
569
+
570
+ Ok(GrpcResponseData {
571
+ payload: Bytes::from(payload),
572
+ metadata,
573
+ })
574
+ })
575
+ .await
576
+ .map_err(|e| tonic::Status::internal(format!("Task join error: {}", e)))?
577
+ })
578
+ }
579
+
580
+ fn service_name(&self) -> &str {
581
+ self.service_name.as_ref()
582
+ }
583
+
584
+ fn call_server_stream(
585
+ &self,
586
+ request: GrpcRequestData,
587
+ ) -> Pin<Box<dyn Future<Output = Result<MessageStream, tonic::Status>> + Send>> {
588
+ let handler = self.handler.clone();
589
+
590
+ Box::pin(async move {
591
+ // Execute Ruby handler in blocking context and collect stream
592
+ let messages = tokio::task::spawn_blocking(move || {
593
+ let ruby = match Ruby::get() {
594
+ Ok(r) => r,
595
+ Err(e) => return Err(tonic::Status::internal(format!("Failed to get Ruby VM: {}", e))),
596
+ };
597
+
598
+ let handler_value = handler.get_inner_with(&ruby);
599
+
600
+ // Create Ruby request
601
+ let ruby_request = Self::create_ruby_request(&ruby, &request).map_err(ruby_error_to_grpc_status)?;
602
+
603
+ // Call handler for server streaming
604
+ let stream_value = Self::call_ruby_server_stream(&ruby, handler_value, ruby_request)
605
+ .map_err(ruby_error_to_grpc_status)?;
606
+
607
+ // Collect stream messages
608
+ Self::collect_ruby_stream(&ruby, stream_value).map_err(ruby_error_to_grpc_status)
609
+ })
610
+ .await
611
+ .map_err(|e| tonic::Status::internal(format!("Task join error: {}", e)))??;
612
+
613
+ // Convert Vec<Bytes> to MessageStream
614
+ let stream = futures::stream::iter(messages.into_iter().map(Ok));
615
+ Ok(Box::pin(stream) as MessageStream)
616
+ })
237
617
  }
238
618
 
239
- fn service_name(&self) -> &'static str {
240
- // We need to return a 'static str, but we have a String.
241
- // For now, we'll leak the string to get a 'static reference.
242
- // This is acceptable because service names are registered once at startup.
243
- Box::leak(self.inner.service_name.clone().into_boxed_str())
619
+ fn call_client_stream(
620
+ &self,
621
+ request: StreamingRequest,
622
+ ) -> Pin<Box<dyn Future<Output = Result<GrpcResponseData, tonic::Status>> + Send>> {
623
+ let handler = self.handler.clone();
624
+
625
+ Box::pin(async move {
626
+ // Collect all incoming messages with size limits
627
+ let mut messages: Vec<Vec<u8>> = Vec::new();
628
+ let mut total_bytes: usize = 0;
629
+ let mut stream = request.message_stream;
630
+
631
+ while let Some(result) = stream.next().await {
632
+ if messages.len() >= MAX_STREAM_MESSAGES {
633
+ return Err(tonic::Status::resource_exhausted(format!(
634
+ "Client stream exceeded maximum {} messages",
635
+ MAX_STREAM_MESSAGES
636
+ )));
637
+ }
638
+ match result {
639
+ Ok(bytes) => {
640
+ total_bytes = total_bytes
641
+ .checked_add(bytes.len())
642
+ .ok_or_else(|| tonic::Status::resource_exhausted("Stream total size overflow"))?;
643
+
644
+ if total_bytes > MAX_STREAM_TOTAL_BYTES {
645
+ return Err(tonic::Status::resource_exhausted(format!(
646
+ "Stream total bytes {} exceeds maximum {}",
647
+ total_bytes, MAX_STREAM_TOTAL_BYTES
648
+ )));
649
+ }
650
+
651
+ messages.push(bytes.to_vec());
652
+ }
653
+ Err(status) => return Err(status),
654
+ }
655
+ }
656
+
657
+ // Execute Ruby handler in blocking context with timeout
658
+ tokio::time::timeout(
659
+ Duration::from_secs(HANDLER_TIMEOUT_SECS),
660
+ tokio::task::spawn_blocking(move || {
661
+ let ruby = match Ruby::get() {
662
+ Ok(r) => r,
663
+ Err(e) => return Err(tonic::Status::internal(format!("Failed to get Ruby VM: {}", e))),
664
+ };
665
+
666
+ let handler_value = handler.get_inner_with(&ruby);
667
+
668
+ // Call handler with collected messages
669
+ let response_value = Self::call_ruby_client_stream(&ruby, handler_value, messages)
670
+ .map_err(ruby_error_to_grpc_status)?;
671
+
672
+ // Extract response
673
+ let (payload, metadata_map) =
674
+ extract_ruby_response(response_value).map_err(ruby_error_to_grpc_status)?;
675
+
676
+ let metadata = hashmap_to_metadata_map(&metadata_map)?;
677
+
678
+ Ok(GrpcResponseData {
679
+ payload: Bytes::from(payload),
680
+ metadata,
681
+ })
682
+ }),
683
+ )
684
+ .await
685
+ .map_err(|_| tonic::Status::deadline_exceeded("Handler timeout"))?
686
+ .map_err(|e| tonic::Status::internal(format!("Task join error: {}", e)))?
687
+ })
688
+ }
689
+
690
+ fn call_bidi_stream(
691
+ &self,
692
+ request: StreamingRequest,
693
+ ) -> Pin<Box<dyn Future<Output = Result<MessageStream, tonic::Status>> + Send>> {
694
+ let handler = self.handler.clone();
695
+
696
+ Box::pin(async move {
697
+ // Collect all incoming messages with size limits
698
+ let mut messages: Vec<Vec<u8>> = Vec::new();
699
+ let mut total_bytes: usize = 0;
700
+ let mut stream = request.message_stream;
701
+
702
+ while let Some(result) = stream.next().await {
703
+ if messages.len() >= MAX_STREAM_MESSAGES {
704
+ return Err(tonic::Status::resource_exhausted(format!(
705
+ "Client stream exceeded maximum {} messages",
706
+ MAX_STREAM_MESSAGES
707
+ )));
708
+ }
709
+ match result {
710
+ Ok(bytes) => {
711
+ total_bytes = total_bytes
712
+ .checked_add(bytes.len())
713
+ .ok_or_else(|| tonic::Status::resource_exhausted("Stream total size overflow"))?;
714
+
715
+ if total_bytes > MAX_STREAM_TOTAL_BYTES {
716
+ return Err(tonic::Status::resource_exhausted(format!(
717
+ "Stream total bytes {} exceeds maximum {}",
718
+ total_bytes, MAX_STREAM_TOTAL_BYTES
719
+ )));
720
+ }
721
+
722
+ messages.push(bytes.to_vec());
723
+ }
724
+ Err(status) => return Err(status),
725
+ }
726
+ }
727
+
728
+ // Execute Ruby handler in blocking context with timeout
729
+ let response_messages = tokio::time::timeout(
730
+ Duration::from_secs(HANDLER_TIMEOUT_SECS),
731
+ tokio::task::spawn_blocking(move || {
732
+ let ruby = match Ruby::get() {
733
+ Ok(r) => r,
734
+ Err(e) => return Err(tonic::Status::internal(format!("Failed to get Ruby VM: {}", e))),
735
+ };
736
+
737
+ let handler_value = handler.get_inner_with(&ruby);
738
+
739
+ // Call handler for bidirectional streaming
740
+ let stream_value = Self::call_ruby_bidi_stream(&ruby, handler_value, messages)
741
+ .map_err(ruby_error_to_grpc_status)?;
742
+
743
+ // Collect response stream
744
+ Self::collect_ruby_stream(&ruby, stream_value).map_err(ruby_error_to_grpc_status)
745
+ }),
746
+ )
747
+ .await
748
+ .map_err(|_| tonic::Status::deadline_exceeded("Handler timeout"))?
749
+ .map_err(|e| tonic::Status::internal(format!("Task join error: {}", e)))??;
750
+
751
+ // Convert Vec<Bytes> to MessageStream
752
+ let stream = futures::stream::iter(response_messages.into_iter().map(Ok));
753
+ Ok(Box::pin(stream) as MessageStream)
754
+ })
244
755
  }
245
756
  }
246
757
 
247
- /// Initialize the gRPC module in Ruby
248
- pub fn init(ruby: &Ruby, spikard_module: &magnus::RModule) -> Result<(), Error> {
758
+ /// Register the Ruby gRPC handler module
759
+ pub fn init(_ruby: &Ruby, spikard_module: &magnus::RModule) -> Result<(), Error> {
249
760
  let grpc_module = spikard_module.define_module("Grpc")?;
250
761
 
251
- // Define Spikard::Grpc::Request class
252
- let request_class = grpc_module.define_class("Request", ruby.class_object())?;
253
- request_class.define_method("service_name", magnus::method!(RubyGrpcRequest::service_name, 0))?;
254
- request_class.define_method("method_name", magnus::method!(RubyGrpcRequest::method_name, 0))?;
255
- request_class.define_method("payload", magnus::method!(RubyGrpcRequest::payload, 0))?;
256
- request_class.define_method("metadata", magnus::method!(RubyGrpcRequest::metadata, 0))?;
257
-
258
- // Define Spikard::Grpc::Response class
259
- let response_class = grpc_module.define_class("Response", ruby.class_object())?;
260
- response_class.define_alloc_func::<RubyGrpcResponse>();
261
- response_class.define_method("initialize", magnus::method!(RubyGrpcResponse::initialize, -1))?;
262
- response_class.define_method("metadata=", magnus::method!(RubyGrpcResponse::set_metadata, 1))?;
263
- response_class.define_method("metadata", magnus::method!(RubyGrpcResponse::get_metadata, 0))?;
264
- response_class.define_method("payload", magnus::method!(RubyGrpcResponse::payload, 0))?;
762
+ // Define Request class
763
+ let request_class = grpc_module.define_class("Request", _ruby.class_object())?;
764
+ request_class.define_method("service_name", magnus::method!(RubyGrpcRequest::rb_service_name, 0))?;
765
+ request_class.define_method("method_name", magnus::method!(RubyGrpcRequest::rb_method_name, 0))?;
766
+ request_class.define_method("payload", magnus::method!(RubyGrpcRequest::rb_payload, 0))?;
767
+ request_class.define_method("metadata", magnus::method!(RubyGrpcRequest::rb_metadata, 0))?;
768
+ request_class.define_method("get_metadata", magnus::method!(RubyGrpcRequest::rb_get_metadata, 1))?;
769
+ request_class.define_method("inspect", magnus::method!(RubyGrpcRequest::rb_inspect, 0))?;
770
+ request_class.define_method("to_s", magnus::method!(RubyGrpcRequest::rb_inspect, 0))?;
771
+
772
+ // Define Response class
773
+ let response_class = grpc_module.define_class("Response", _ruby.class_object())?;
774
+ response_class.define_singleton_method("new", magnus::function!(RubyGrpcResponse::rb_new, -1))?;
775
+ response_class.define_method("payload", magnus::method!(RubyGrpcResponse::rb_payload, 0))?;
776
+ response_class.define_method("payload=", magnus::method!(RubyGrpcResponse::rb_set_payload, 1))?;
777
+ response_class.define_method("metadata", magnus::method!(RubyGrpcResponse::rb_metadata, 0))?;
778
+ response_class.define_method("metadata=", magnus::method!(RubyGrpcResponse::rb_set_metadata, 1))?;
779
+ response_class.define_method("inspect", magnus::method!(RubyGrpcResponse::rb_inspect, 0))?;
780
+ response_class.define_method("to_s", magnus::method!(RubyGrpcResponse::rb_inspect, 0))?;
265
781
 
266
782
  Ok(())
267
783
  }
@@ -269,89 +785,50 @@ pub fn init(ruby: &Ruby, spikard_module: &magnus::RModule) -> Result<(), Error>
269
785
  #[cfg(test)]
270
786
  mod tests {
271
787
  use super::*;
272
- use bytes::Bytes;
273
- use tonic::metadata::MetadataMap;
274
788
 
275
789
  #[test]
276
- fn test_ruby_grpc_request_creation() {
277
- let request = GrpcRequestData {
278
- service_name: "test.TestService".to_string(),
279
- method_name: "TestMethod".to_string(),
280
- payload: Bytes::from("test payload"),
281
- metadata: MetadataMap::new(),
282
- };
283
-
284
- let ruby_request = RubyGrpcRequest::from_grpc_request(request);
285
- assert_eq!(ruby_request.service_name, "test.TestService");
286
- assert_eq!(ruby_request.method_name, "TestMethod");
287
- assert_eq!(ruby_request.payload, b"test payload");
288
- }
790
+ fn test_metadata_conversion() {
791
+ let mut map = HashMap::new();
792
+ map.insert("authorization".to_string(), "Bearer token".to_string());
793
+ map.insert("content-type".to_string(), "application/grpc".to_string());
289
794
 
290
- #[test]
291
- fn test_metadata_extraction() {
292
- use spikard_bindings_shared::grpc_metadata::extract_metadata_to_hashmap;
293
-
294
- let mut metadata = MetadataMap::new();
295
- metadata.insert(
296
- "content-type",
297
- "application/grpc".parse().expect("Valid metadata value"),
298
- );
299
- metadata.insert(
300
- "authorization",
301
- "Bearer token123".parse().expect("Valid metadata value"),
302
- );
795
+ let metadata = hashmap_to_metadata_map(&map).expect("valid metadata map");
796
+ let converted = metadata_map_to_hashmap(&metadata);
303
797
 
304
- let extracted = extract_metadata_to_hashmap(&metadata, false);
305
- assert_eq!(
306
- extracted.get("content-type").expect("content-type header"),
307
- "application/grpc"
308
- );
309
- assert_eq!(
310
- extracted.get("authorization").expect("authorization header"),
311
- "Bearer token123"
312
- );
313
- }
314
-
315
- #[test]
316
- fn test_grpc_response_conversion() {
317
- let response = RubyGrpcResponse {
318
- payload: RefCell::new(b"test response".to_vec()),
319
- metadata: RefCell::new(HashMap::new()),
320
- };
321
-
322
- let grpc_response = response.into_grpc_response();
323
- assert!(grpc_response.is_ok());
324
- let grpc_response = grpc_response.expect("Valid grpc response");
325
- assert_eq!(grpc_response.payload, Bytes::from("test response"));
798
+ assert_eq!(converted.get("authorization"), Some(&"Bearer token".to_string()));
799
+ assert_eq!(converted.get("content-type"), Some(&"application/grpc".to_string()));
326
800
  }
327
801
 
328
802
  #[test]
329
- fn test_grpc_response_with_metadata() {
803
+ fn test_ruby_grpc_request_creation() {
330
804
  let mut metadata = HashMap::new();
331
- metadata.insert("x-custom-header".to_string(), "custom-value".to_string());
805
+ metadata.insert("key".to_string(), "value".to_string());
332
806
 
333
- let response = RubyGrpcResponse {
334
- payload: RefCell::new(b"test".to_vec()),
335
- metadata: RefCell::new(metadata),
336
- };
807
+ let request = RubyGrpcRequest::new(
808
+ "test.Service".to_string(),
809
+ "Method".to_string(),
810
+ vec![1, 2, 3],
811
+ metadata,
812
+ );
337
813
 
338
- let grpc_response = response.into_grpc_response();
339
- assert!(grpc_response.is_ok());
340
- let grpc_response = grpc_response.expect("Valid grpc response");
341
- assert!(!grpc_response.metadata.is_empty());
814
+ assert_eq!(request.service_name, "test.Service");
815
+ assert_eq!(request.method_name, "Method");
816
+ assert_eq!(request.payload, vec![1, 2, 3]);
817
+ assert_eq!(request.metadata.get("key"), Some(&"value".to_string()));
342
818
  }
343
819
 
344
820
  #[test]
345
- fn test_invalid_metadata_key() {
346
- let mut metadata = HashMap::new();
347
- metadata.insert("invalid\nkey".to_string(), "value".to_string());
348
-
349
- let response = RubyGrpcResponse {
350
- payload: RefCell::new(b"test".to_vec()),
351
- metadata: RefCell::new(metadata),
352
- };
821
+ fn test_inspect_format() {
822
+ let request = RubyGrpcRequest::new(
823
+ "test.Service".to_string(),
824
+ "Method".to_string(),
825
+ vec![1, 2, 3, 4, 5],
826
+ HashMap::new(),
827
+ );
353
828
 
354
- let result = response.into_grpc_response();
355
- assert!(result.is_err());
829
+ let repr = request.rb_inspect();
830
+ assert!(repr.contains("test.Service"));
831
+ assert!(repr.contains("Method"));
832
+ assert!(repr.contains("payload_size=5"));
356
833
  }
357
834
  }