spikard 0.7.5 → 0.8.0

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/ext/spikard_rb/Cargo.lock +583 -201
  3. data/ext/spikard_rb/Cargo.toml +1 -1
  4. data/lib/spikard/grpc.rb +182 -0
  5. data/lib/spikard/version.rb +1 -1
  6. data/lib/spikard.rb +1 -0
  7. data/vendor/crates/spikard-bindings-shared/Cargo.toml +2 -1
  8. data/vendor/crates/spikard-bindings-shared/src/grpc_metadata.rs +197 -0
  9. data/vendor/crates/spikard-bindings-shared/src/lib.rs +2 -0
  10. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  11. data/vendor/crates/spikard-http/Cargo.toml +5 -1
  12. data/vendor/crates/spikard-http/src/grpc/handler.rs +260 -0
  13. data/vendor/crates/spikard-http/src/grpc/mod.rs +342 -0
  14. data/vendor/crates/spikard-http/src/grpc/service.rs +392 -0
  15. data/vendor/crates/spikard-http/src/grpc/streaming.rs +237 -0
  16. data/vendor/crates/spikard-http/src/lib.rs +14 -0
  17. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +288 -0
  18. data/vendor/crates/spikard-http/src/server/mod.rs +1 -0
  19. data/vendor/crates/spikard-http/tests/common/grpc_helpers.rs +1023 -0
  20. data/vendor/crates/spikard-http/tests/common/mod.rs +8 -0
  21. data/vendor/crates/spikard-http/tests/grpc_error_handling_test.rs +653 -0
  22. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +332 -0
  23. data/vendor/crates/spikard-http/tests/grpc_metadata_test.rs +518 -0
  24. data/vendor/crates/spikard-http/tests/grpc_server_integration.rs +476 -0
  25. data/vendor/crates/spikard-rb/Cargo.toml +2 -1
  26. data/vendor/crates/spikard-rb/src/config/server_config.rs +1 -0
  27. data/vendor/crates/spikard-rb/src/grpc/handler.rs +352 -0
  28. data/vendor/crates/spikard-rb/src/grpc/mod.rs +9 -0
  29. data/vendor/crates/spikard-rb/src/lib.rs +4 -0
  30. data/vendor/crates/spikard-rb-macros/Cargo.toml +1 -1
  31. metadata +15 -1
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-rb-ext"
3
- version = "0.7.5"
3
+ version = "0.8.0"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ # gRPC support for Spikard
5
+ #
6
+ # This module provides Ruby bindings for handling gRPC requests through
7
+ # Spikard's Rust-based gRPC runtime. Handlers receive protobuf messages
8
+ # as binary strings and use the google-protobuf gem for serialization.
9
+ #
10
+ # @example Basic gRPC handler
11
+ # require 'spikard/grpc'
12
+ # require 'user_pb' # Generated protobuf
13
+ #
14
+ # class UserServiceHandler < Spikard::Grpc::Handler
15
+ # def handle_request(request)
16
+ # case request.method_name
17
+ # when 'GetUser'
18
+ # # Deserialize request
19
+ # req = Example::GetUserRequest.decode(request.payload)
20
+ #
21
+ # # Process request
22
+ # user = Example::User.new(id: req.id, name: 'John Doe')
23
+ #
24
+ # # Serialize response
25
+ # Spikard::Grpc::Response.new(payload: Example::User.encode(user))
26
+ # else
27
+ # raise "Unknown method: #{request.method_name}"
28
+ # end
29
+ # end
30
+ # end
31
+ module Grpc
32
+ # gRPC request object
33
+ #
34
+ # Represents an incoming gRPC request with service/method information
35
+ # and a binary protobuf payload.
36
+ #
37
+ # @!attribute [r] service_name
38
+ # @return [String] Fully qualified service name (e.g., "mypackage.MyService")
39
+ # @!attribute [r] method_name
40
+ # @return [String] Method name (e.g., "GetUser")
41
+ # @!attribute [r] payload
42
+ # @return [String] Binary string containing serialized protobuf message
43
+ # @!attribute [r] metadata
44
+ # @return [Hash<String, String>] gRPC metadata (headers)
45
+ class Request
46
+ # These methods are implemented in Rust via Magnus FFI
47
+ # See: crates/spikard-rb/src/grpc/handler.rs
48
+ end
49
+
50
+ # gRPC response object
51
+ #
52
+ # Used to return gRPC responses from handlers. The payload should be
53
+ # a binary string containing a serialized protobuf message.
54
+ #
55
+ # @example Creating a response
56
+ # user = Example::User.new(id: 1, name: 'Alice')
57
+ # response = Spikard::Grpc::Response.new(payload: Example::User.encode(user))
58
+ #
59
+ # @example Adding metadata
60
+ # response = Spikard::Grpc::Response.new(payload: encoded_message)
61
+ # response.metadata = { 'x-custom-header' => 'value' }
62
+ class Response
63
+ # @!attribute [w] metadata
64
+ # @return [Hash<String, String>] gRPC metadata to include in response
65
+
66
+ # Create a new gRPC response
67
+ #
68
+ # @param payload [String] Binary string containing serialized protobuf message
69
+ # @raise [ArgumentError] if payload is not a String
70
+ #
71
+ # Note: Implementation in Rust (Magnus FFI)
72
+ # See: crates/spikard-rb/src/grpc/handler.rs
73
+
74
+ # Create an error response
75
+ #
76
+ # @param message [String] Error message
77
+ # @param metadata [Hash<String, String>] Optional gRPC metadata
78
+ # @return [Response] A response with error status
79
+ #
80
+ # @example
81
+ # response = Spikard::Grpc::Response.error('Method not implemented')
82
+ def self.error(message, metadata = {})
83
+ error_metadata = metadata.merge(
84
+ 'grpc-status' => 'INTERNAL',
85
+ 'grpc-message' => message
86
+ )
87
+ response = new(payload: '')
88
+ response.metadata = error_metadata
89
+ response
90
+ end
91
+ end
92
+
93
+ # Base class for gRPC handlers
94
+ #
95
+ # Subclass this to implement gRPC service handlers. Override
96
+ # {#handle_request} to process incoming requests.
97
+ #
98
+ # @example Implementing a handler
99
+ # class MyServiceHandler < Spikard::Grpc::Handler
100
+ # def handle_request(request)
101
+ # case request.method_name
102
+ # when 'MethodOne'
103
+ # # Handle MethodOne
104
+ # req = MyPackage::MethodOneRequest.decode(request.payload)
105
+ # resp = MyPackage::MethodOneResponse.new(...)
106
+ # Spikard::Grpc::Response.new(payload: MyPackage::MethodOneResponse.encode(resp))
107
+ # when 'MethodTwo'
108
+ # # Handle MethodTwo
109
+ # # ...
110
+ # else
111
+ # raise "Unknown method: #{request.method_name}"
112
+ # end
113
+ # end
114
+ # end
115
+ class Handler
116
+ # Handle a gRPC request
117
+ #
118
+ # This method must be overridden by subclasses to implement the
119
+ # actual request handling logic.
120
+ #
121
+ # @param request [Spikard::Grpc::Request] The incoming gRPC request
122
+ # @return [Spikard::Grpc::Response] The gRPC response
123
+ # @raise [NotImplementedError] if not overridden by subclass
124
+ def handle_request(request)
125
+ raise NotImplementedError, "#{self.class}#handle_request must be implemented"
126
+ end
127
+ end
128
+
129
+ # Service registry for gRPC handlers
130
+ #
131
+ # Manages registration and lookup of gRPC service handlers.
132
+ # Handlers are registered by service name and method.
133
+ #
134
+ # @example Registering a handler
135
+ # service = Spikard::Grpc::Service.new
136
+ # handler = UserServiceHandler.new
137
+ # service.register_handler('mypackage.UserService', handler)
138
+ class Service
139
+ def initialize
140
+ @handlers = {}
141
+ end
142
+
143
+ # Register a gRPC handler for a service
144
+ #
145
+ # @param service_name [String] Fully qualified service name
146
+ # @param handler [Spikard::Grpc::Handler] Handler instance
147
+ # @raise [ArgumentError] if service_name is invalid or handler doesn't respond to handle_request
148
+ def register_handler(service_name, handler)
149
+ raise ArgumentError, 'Service name cannot be empty' if service_name.nil? || service_name.empty?
150
+
151
+ unless handler.respond_to?(:handle_request)
152
+ raise ArgumentError, "Handler must respond to :handle_request"
153
+ end
154
+
155
+ @handlers[service_name] = handler
156
+ end
157
+
158
+ # Get a handler by service name
159
+ #
160
+ # @param service_name [String] Fully qualified service name
161
+ # @return [Spikard::Grpc::Handler, nil] The handler or nil if not found
162
+ def get_handler(service_name)
163
+ @handlers[service_name]
164
+ end
165
+
166
+ # Get all registered service names
167
+ #
168
+ # @return [Array<String>] List of registered service names
169
+ def service_names
170
+ @handlers.keys
171
+ end
172
+
173
+ # Check if a service is registered
174
+ #
175
+ # @param service_name [String] Fully qualified service name
176
+ # @return [Boolean] true if the service is registered
177
+ def registered?(service_name)
178
+ @handlers.key?(service_name)
179
+ end
180
+ end
181
+ end
182
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spikard
4
- VERSION = '0.7.5'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/spikard.rb CHANGED
@@ -17,6 +17,7 @@ require_relative 'spikard/background'
17
17
  require_relative 'spikard/schema'
18
18
  require_relative 'spikard/websocket'
19
19
  require_relative 'spikard/sse'
20
+ require_relative 'spikard/grpc'
20
21
  require_relative 'spikard/upload_file'
21
22
  require_relative 'spikard/converters'
22
23
  require_relative 'spikard/provide'
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-bindings-shared"
3
- version = "0.7.5"
3
+ version = "0.8.0"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"
@@ -18,6 +18,7 @@ spikard-http = { path = "../spikard-http" }
18
18
  tracing = "0.1"
19
19
  http = "1.4"
20
20
  http-body-util = "0.1"
21
+ tonic = "0.14"
21
22
 
22
23
  [features]
23
24
  default = []
@@ -0,0 +1,197 @@
1
+ //! Shared gRPC metadata utilities
2
+ //!
3
+ //! This module provides common metadata conversion functions used across all
4
+ //! language bindings (Python, Node.js, Ruby, PHP) to avoid code duplication.
5
+
6
+ use std::collections::HashMap;
7
+ use tonic::metadata::{MetadataMap, MetadataKey, MetadataValue};
8
+
9
+ /// Extract metadata from gRPC MetadataMap to a simple HashMap.
10
+ ///
11
+ /// This function converts gRPC metadata to a language-agnostic HashMap format
12
+ /// that can be easily passed to language bindings. Only ASCII metadata is
13
+ /// included; binary metadata is skipped with optional logging.
14
+ ///
15
+ /// # Arguments
16
+ ///
17
+ /// * `metadata` - The gRPC MetadataMap to extract from
18
+ /// * `log_binary_skip` - Whether to log when binary metadata is skipped
19
+ ///
20
+ /// # Returns
21
+ ///
22
+ /// A HashMap containing all ASCII metadata key-value pairs
23
+ ///
24
+ /// # Examples
25
+ ///
26
+ /// ```
27
+ /// use tonic::metadata::MetadataMap;
28
+ /// use spikard_bindings_shared::grpc_metadata::extract_metadata_to_hashmap;
29
+ ///
30
+ /// let mut metadata = MetadataMap::new();
31
+ /// metadata.insert("authorization", "Bearer token123".parse().unwrap());
32
+ ///
33
+ /// let map = extract_metadata_to_hashmap(&metadata, false);
34
+ /// assert_eq!(map.get("authorization"), Some(&"Bearer token123".to_string()));
35
+ /// ```
36
+ pub fn extract_metadata_to_hashmap(metadata: &MetadataMap, log_binary_skip: bool) -> HashMap<String, String> {
37
+ let mut map = HashMap::new();
38
+
39
+ for key_value in metadata.iter() {
40
+ match key_value {
41
+ tonic::metadata::KeyAndValueRef::Ascii(key, value) => {
42
+ let key_str = key.as_str().to_string();
43
+ let value_str = value.to_str().unwrap_or("").to_string();
44
+ map.insert(key_str, value_str);
45
+ }
46
+ tonic::metadata::KeyAndValueRef::Binary(key, _value) => {
47
+ // Binary metadata is skipped as we only support string values
48
+ if log_binary_skip {
49
+ tracing::debug!("Skipping binary metadata key: {}", key.as_str());
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ map
56
+ }
57
+
58
+ /// Convert a HashMap to gRPC MetadataMap.
59
+ ///
60
+ /// This function converts a language-agnostic HashMap into a gRPC MetadataMap
61
+ /// that can be used in responses. All keys and values are validated and errors
62
+ /// are returned if any are invalid.
63
+ ///
64
+ /// # Arguments
65
+ ///
66
+ /// * `map` - The HashMap to convert
67
+ ///
68
+ /// # Returns
69
+ ///
70
+ /// A Result containing the MetadataMap or an error message
71
+ ///
72
+ /// # Errors
73
+ ///
74
+ /// Returns an error if:
75
+ /// - A metadata key is invalid (contains invalid characters)
76
+ /// - A metadata value is invalid (contains invalid characters)
77
+ ///
78
+ /// # Examples
79
+ ///
80
+ /// ```
81
+ /// use std::collections::HashMap;
82
+ /// use spikard_bindings_shared::grpc_metadata::hashmap_to_metadata;
83
+ ///
84
+ /// let mut map = HashMap::new();
85
+ /// map.insert("content-type".to_string(), "application/grpc".to_string());
86
+ ///
87
+ /// let metadata = hashmap_to_metadata(&map).unwrap();
88
+ /// assert!(metadata.contains_key("content-type"));
89
+ /// ```
90
+ pub fn hashmap_to_metadata(map: &HashMap<String, String>) -> Result<MetadataMap, String> {
91
+ let mut metadata = MetadataMap::new();
92
+
93
+ for (key, value) in map {
94
+ let metadata_key = MetadataKey::from_bytes(key.as_bytes())
95
+ .map_err(|err| format!("Invalid metadata key '{}': {}", key, err))?;
96
+
97
+ let metadata_value = MetadataValue::try_from(value)
98
+ .map_err(|err| format!("Invalid metadata value for '{}': {}", key, err))?;
99
+
100
+ metadata.insert(metadata_key, metadata_value);
101
+ }
102
+
103
+ Ok(metadata)
104
+ }
105
+
106
+ #[cfg(test)]
107
+ mod tests {
108
+ use super::*;
109
+
110
+ #[test]
111
+ fn test_extract_empty_metadata() {
112
+ let metadata = MetadataMap::new();
113
+ let map = extract_metadata_to_hashmap(&metadata, false);
114
+ assert!(map.is_empty());
115
+ }
116
+
117
+ #[test]
118
+ fn test_extract_single_metadata() {
119
+ let mut metadata = MetadataMap::new();
120
+ metadata.insert("content-type", "application/grpc".parse().unwrap());
121
+
122
+ let map = extract_metadata_to_hashmap(&metadata, false);
123
+ assert_eq!(map.len(), 1);
124
+ assert_eq!(map.get("content-type"), Some(&"application/grpc".to_string()));
125
+ }
126
+
127
+ #[test]
128
+ fn test_extract_multiple_metadata() {
129
+ let mut metadata = MetadataMap::new();
130
+ metadata.insert("content-type", "application/grpc".parse().unwrap());
131
+ metadata.insert("authorization", "Bearer token123".parse().unwrap());
132
+ metadata.insert("x-custom-header", "custom-value".parse().unwrap());
133
+
134
+ let map = extract_metadata_to_hashmap(&metadata, false);
135
+ assert_eq!(map.len(), 3);
136
+ assert_eq!(map.get("content-type"), Some(&"application/grpc".to_string()));
137
+ assert_eq!(map.get("authorization"), Some(&"Bearer token123".to_string()));
138
+ assert_eq!(map.get("x-custom-header"), Some(&"custom-value".to_string()));
139
+ }
140
+
141
+ #[test]
142
+ fn test_hashmap_to_metadata_empty() {
143
+ let map = HashMap::new();
144
+ let metadata = hashmap_to_metadata(&map).unwrap();
145
+ assert_eq!(metadata.len(), 0);
146
+ }
147
+
148
+ #[test]
149
+ fn test_hashmap_to_metadata_single() {
150
+ let mut map = HashMap::new();
151
+ map.insert("content-type".to_string(), "application/grpc".to_string());
152
+
153
+ let metadata = hashmap_to_metadata(&map).unwrap();
154
+ assert_eq!(metadata.len(), 1);
155
+ assert!(metadata.contains_key("content-type"));
156
+ }
157
+
158
+ #[test]
159
+ fn test_hashmap_to_metadata_multiple() {
160
+ let mut map = HashMap::new();
161
+ map.insert("content-type".to_string(), "application/grpc".to_string());
162
+ map.insert("authorization".to_string(), "Bearer token".to_string());
163
+
164
+ let metadata = hashmap_to_metadata(&map).unwrap();
165
+ assert_eq!(metadata.len(), 2);
166
+ assert!(metadata.contains_key("content-type"));
167
+ assert!(metadata.contains_key("authorization"));
168
+ }
169
+
170
+ #[test]
171
+ fn test_hashmap_to_metadata_invalid_key() {
172
+ let mut map = HashMap::new();
173
+ map.insert("invalid\nkey".to_string(), "value".to_string());
174
+
175
+ let result = hashmap_to_metadata(&map);
176
+ assert!(result.is_err());
177
+ assert!(result.unwrap_err().contains("Invalid metadata key"));
178
+ }
179
+
180
+ #[test]
181
+ fn test_roundtrip_metadata() {
182
+ let mut original_metadata = MetadataMap::new();
183
+ original_metadata.insert("content-type", "application/grpc".parse().unwrap());
184
+ original_metadata.insert("x-custom", "value".parse().unwrap());
185
+
186
+ // Extract to HashMap
187
+ let map = extract_metadata_to_hashmap(&original_metadata, false);
188
+
189
+ // Convert back to MetadataMap
190
+ let new_metadata = hashmap_to_metadata(&map).unwrap();
191
+
192
+ // Verify both have the same entries
193
+ assert_eq!(new_metadata.len(), original_metadata.len());
194
+ assert!(new_metadata.contains_key("content-type"));
195
+ assert!(new_metadata.contains_key("x-custom"));
196
+ }
197
+ }
@@ -8,6 +8,7 @@ pub mod config_extractor;
8
8
  pub mod conversion_traits;
9
9
  pub mod di_traits;
10
10
  pub mod error_response;
11
+ pub mod grpc_metadata;
11
12
  pub mod handler_base;
12
13
  pub mod lifecycle_base;
13
14
  pub mod lifecycle_executor;
@@ -18,6 +19,7 @@ pub mod validation_helpers;
18
19
  pub use config_extractor::{ConfigExtractor, ConfigSource};
19
20
  pub use di_traits::{FactoryDependencyAdapter, ValueDependencyAdapter};
20
21
  pub use error_response::ErrorResponseBuilder;
22
+ pub use grpc_metadata::{extract_metadata_to_hashmap, hashmap_to_metadata};
21
23
  pub use handler_base::{HandlerError, HandlerExecutor, LanguageHandler};
22
24
  pub use lifecycle_executor::{
23
25
  HookResultData, LanguageLifecycleHook, LifecycleExecutor, RequestModifications, extract_body,
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-core"
3
- version = "0.7.5"
3
+ version = "0.8.0"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-http"
3
- version = "0.7.5"
3
+ version = "0.8.0"
4
4
  edition = "2024"
5
5
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
6
6
  license = "MIT"
@@ -50,6 +50,10 @@ cookie = "0.18"
50
50
  base64 = "0.22.1"
51
51
  flate2 = { version = "=1.1.5", default-features = false, features = ["rust_backend"] }
52
52
  brotli = "8.0"
53
+ tonic = { version = "0.14", features = ["transport", "codegen", "gzip"] }
54
+ prost = "0.14"
55
+ prost-types = "0.14"
56
+ h2 = "0.4"
53
57
 
54
58
 
55
59
  [features]