spikard 0.11.0 → 0.12.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/ext/spikard_rb/Cargo.toml +1 -1
  3. data/lib/spikard/config.rb +36 -2
  4. data/lib/spikard/testing.rb +136 -0
  5. data/lib/spikard/version.rb +1 -1
  6. data/lib/spikard.rb +3 -7
  7. data/sig/spikard.rbs +422 -73
  8. data/vendor/crates/spikard-bindings-shared/Cargo.toml +12 -1
  9. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +118 -3
  10. data/vendor/crates/spikard-core/Cargo.toml +4 -1
  11. data/vendor/crates/spikard-http/Cargo.toml +16 -4
  12. data/vendor/crates/spikard-http/src/auth.rs +8 -3
  13. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +14 -11
  14. data/vendor/crates/spikard-http/src/server/mod.rs +16 -67
  15. data/vendor/crates/spikard-http/src/testing/test_client.rs +334 -11
  16. data/vendor/crates/spikard-http/src/testing.rs +1 -1
  17. data/vendor/crates/spikard-http/tests/auth_integration.rs +4 -6
  18. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +3 -3
  19. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +85 -1
  20. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +1 -1
  21. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +1 -1
  22. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +6 -8
  23. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +3 -3
  24. data/vendor/crates/spikard-http/tests/testing_helpers.rs +2 -2
  25. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +4 -5
  26. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +2 -2
  27. data/vendor/crates/spikard-http/tests/websocket_integration.rs +2 -4
  28. data/vendor/crates/spikard-rb/Cargo.toml +11 -2
  29. data/vendor/crates/spikard-rb/src/config/server_config.rs +120 -5
  30. data/vendor/crates/spikard-rb/src/lib.rs +2 -16
  31. data/vendor/crates/spikard-rb/src/testing/client.rs +2 -2
  32. data/vendor/crates/spikard-rb-macros/Cargo.toml +4 -1
  33. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddca560615174394e71fc0066ee7749daa3fcc5e91c8387c3abcc6e8685c7f82
4
- data.tar.gz: 273523e0e3aa0068e95b660744bf4278cfc6b157d61a9d935ac6953da7431fb9
3
+ metadata.gz: b6d3ace9a6aa31de25bf31238a3a2425546c5137f85036cce599ac251cf63e25
4
+ data.tar.gz: b60af042fe08fca1e779d15fea771e6f29c864491cad98260fd3c1aa4887f538
5
5
  SHA512:
6
- metadata.gz: 0b6005c4cc1e6f32e2e5b5157874b76b0d797931a792c12bf07324f04a83ba17210fd56ff8a495534056a7ac85bd07bcbc6af487c071c41ff0f6aa76dbfe6e67
7
- data.tar.gz: 342a8ed2c1d180180701aedb5cc01467eadb3da8317310678b4b9857a002ba09231b5b1c5d6a85fc0f07c1e432a6cb45a97a92fe30c583a5abe406b625494dd2
6
+ metadata.gz: 8786dce5521a0d9d4c38dbcb4c433bed930bc77dba34ad149a42a00d03803ffba58318f87c44abee93175d842ba2d94bb3af0b3c6a0e156179d431c441921473
7
+ data.tar.gz: 6cf9e10315120acf2593693f9a998487e0c3613ba50a3c4693ae408747f7b9efae82cc57a4962c9d32eb337745593e70fbc00bd2cb1e57949d72363db0da265b
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "spikard-rb-ext"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
@@ -340,6 +340,37 @@ module Spikard
340
340
  end
341
341
  end
342
342
 
343
+ # JSON-RPC endpoint configuration.
344
+ class JsonRpcConfig
345
+ attr_accessor :enabled, :endpoint_path, :enable_batch, :max_batch_size
346
+
347
+ # @param enabled [Boolean] Enable JSON-RPC endpoint registration (default: true)
348
+ # @param endpoint_path [String] JSON-RPC endpoint path (default: "/rpc")
349
+ # @param enable_batch [Boolean] Enable JSON-RPC batch support (default: true)
350
+ # @param max_batch_size [Integer] Maximum batch size (default: 100)
351
+ def initialize(enabled: true, endpoint_path: '/rpc', enable_batch: true, max_batch_size: 100)
352
+ @enabled = normalize_boolean('enabled', enabled)
353
+ @endpoint_path = endpoint_path
354
+ @enable_batch = normalize_boolean('enable_batch', enable_batch)
355
+ @max_batch_size = normalize_positive_integer('max_batch_size', max_batch_size)
356
+ end
357
+
358
+ private
359
+
360
+ def normalize_boolean(name, value)
361
+ return value if [true, false].include?(value)
362
+
363
+ raise ArgumentError, "#{name} must be a boolean"
364
+ end
365
+
366
+ def normalize_positive_integer(name, value)
367
+ raise ArgumentError, "#{name} must be an Integer" unless value.is_a?(Integer)
368
+ return value if value.positive?
369
+
370
+ raise ArgumentError, "#{name} must be > 0"
371
+ end
372
+ end
373
+
343
374
  # Complete server configuration for Spikard.
344
375
  #
345
376
  # This is the main configuration object that controls all aspects of the server
@@ -369,7 +400,7 @@ module Spikard
369
400
  :enable_request_id, :max_body_size, :request_timeout,
370
401
  :compression, :rate_limit, :jwt_auth, :api_key_auth,
371
402
  :static_files, :graceful_shutdown, :shutdown_timeout,
372
- :openapi
403
+ :openapi, :jsonrpc
373
404
 
374
405
  # @param host [String] Host address to bind to (default: "127.0.0.1")
375
406
  # @param port [Integer] Port number to listen on (default: 8000, range: 1-65535)
@@ -385,6 +416,7 @@ module Spikard
385
416
  # @param graceful_shutdown [Boolean] Enable graceful shutdown (default: true)
386
417
  # @param shutdown_timeout [Integer] Graceful shutdown timeout in seconds (default: 30)
387
418
  # @param openapi [OpenApiConfig, nil] OpenAPI configuration (default: nil/disabled)
419
+ # @param jsonrpc [JsonRpcConfig, nil] JSON-RPC configuration (default: nil/disabled)
388
420
  def initialize(
389
421
  host: '127.0.0.1',
390
422
  port: 8000,
@@ -399,7 +431,8 @@ module Spikard
399
431
  static_files: [],
400
432
  graceful_shutdown: true,
401
433
  shutdown_timeout: 30,
402
- openapi: nil
434
+ openapi: nil,
435
+ jsonrpc: nil
403
436
  )
404
437
  @host = host
405
438
  @port = normalize_port(port)
@@ -415,6 +448,7 @@ module Spikard
415
448
  @graceful_shutdown = normalize_boolean('graceful_shutdown', graceful_shutdown)
416
449
  @shutdown_timeout = normalize_timeout('shutdown_timeout', shutdown_timeout)
417
450
  @openapi = openapi
451
+ @jsonrpc = jsonrpc
418
452
  end
419
453
 
420
454
  private
@@ -6,6 +6,8 @@ require 'timeout'
6
6
  module Spikard
7
7
  # Testing helpers that wrap the native Ruby extension.
8
8
  module Testing
9
+ GRAPHQL_WS_MAX_CONTROL_MESSAGES = 32
10
+
9
11
  module_function
10
12
 
11
13
  def create_test_client(app, config: nil)
@@ -45,6 +47,7 @@ module Spikard
45
47
  end
46
48
 
47
49
  # High level wrapper around the native test client.
50
+ # rubocop:disable Metrics/ClassLength
48
51
  class TestClient
49
52
  def initialize(native)
50
53
  @native = native
@@ -77,6 +80,118 @@ module Spikard
77
80
  SseStream.new(native_sse)
78
81
  end
79
82
 
83
+ def graphql(query, variables = nil, operation_name = nil, path: '/graphql')
84
+ payload = { query: query }
85
+ payload[:variables] = variables unless variables.nil?
86
+ payload[:operationName] = operation_name unless operation_name.nil?
87
+ request('POST', path, nil, nil, json: payload)
88
+ end
89
+
90
+ def graphql_with_status(query, variables = nil, operation_name = nil, path: '/graphql')
91
+ response = graphql(query, variables, operation_name, path: path)
92
+ [response.status, response]
93
+ end
94
+
95
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity
96
+ # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
97
+ def graphql_subscription(query, variables = nil, operation_name = nil, path: '/graphql')
98
+ operation_id = 'spikard-subscription-1'
99
+ payload = { query: query }
100
+ payload[:variables] = variables unless variables.nil?
101
+ payload[:operationName] = operation_name unless operation_name.nil?
102
+
103
+ ws = websocket(path)
104
+ ws.send_json({ 'type' => 'connection_init' })
105
+
106
+ acknowledged = false
107
+ GRAPHQL_WS_MAX_CONTROL_MESSAGES.times do
108
+ message = websocket_protocol_message(ws.receive_json)
109
+ message_type = websocket_field(message, :type)
110
+
111
+ if message_type == 'connection_ack'
112
+ acknowledged = true
113
+ break
114
+ end
115
+
116
+ if message_type == 'ping'
117
+ pong = { 'type' => 'pong' }
118
+ pong['payload'] = websocket_field(message, :payload) if message.key?('payload') || message.key?(:payload)
119
+ ws.send_json(pong)
120
+ next
121
+ end
122
+
123
+ if %w[connection_error error].include?(message_type)
124
+ raise "GraphQL subscription rejected during init: #{message}"
125
+ end
126
+ end
127
+
128
+ raise 'No GraphQL connection_ack received' unless acknowledged
129
+
130
+ ws.send_json({
131
+ 'id' => operation_id,
132
+ 'type' => 'subscribe',
133
+ 'payload' => payload
134
+ })
135
+
136
+ event = nil
137
+ errors = []
138
+ complete_received = false
139
+
140
+ GRAPHQL_WS_MAX_CONTROL_MESSAGES.times do
141
+ message = websocket_protocol_message(ws.receive_json)
142
+ message_type = websocket_field(message, :type)
143
+ message_id = websocket_field(message, :id)
144
+ id_matches = message_id.nil? || message_id == operation_id
145
+
146
+ if message_type == 'next' && id_matches
147
+ event = websocket_field(message, :payload)
148
+ ws.send_json({ 'id' => operation_id, 'type' => 'complete' })
149
+
150
+ begin
151
+ maybe_complete = websocket_protocol_message(ws.receive_json)
152
+ complete_type = websocket_field(maybe_complete, :type)
153
+ complete_id = websocket_field(maybe_complete, :id)
154
+ complete_received = complete_type == 'complete' && (complete_id.nil? || complete_id == operation_id)
155
+ rescue StandardError
156
+ # Some servers close immediately after client complete.
157
+ end
158
+ break
159
+ end
160
+
161
+ if message_type == 'error'
162
+ errors << (websocket_field(message, :payload) || message)
163
+ break
164
+ end
165
+
166
+ if message_type == 'complete' && id_matches
167
+ complete_received = true
168
+ break
169
+ end
170
+
171
+ next unless message_type == 'ping'
172
+
173
+ pong = { 'type' => 'pong' }
174
+ pong['payload'] = websocket_field(message, :payload) if message.key?('payload') || message.key?(:payload)
175
+ ws.send_json(pong)
176
+ end
177
+
178
+ if event.nil? && errors.empty? && !complete_received
179
+ raise 'No GraphQL subscription event received before timeout'
180
+ end
181
+
182
+ {
183
+ 'operation_id' => operation_id,
184
+ 'acknowledged' => true,
185
+ 'event' => event,
186
+ 'errors' => errors,
187
+ 'complete_received' => complete_received
188
+ }
189
+ ensure
190
+ ws&.close
191
+ end
192
+ # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
193
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity
194
+
80
195
  def close
81
196
  @native.close
82
197
  end
@@ -123,7 +238,28 @@ module Spikard
123
238
  payload[:files] = files if files
124
239
  payload
125
240
  end
241
+
242
+ def websocket_protocol_message(raw)
243
+ case raw
244
+ when String
245
+ parsed = JSON.parse(raw)
246
+ raise 'Expected GraphQL WebSocket JSON object message' unless parsed.is_a?(Hash)
247
+
248
+ parsed
249
+ when Hash
250
+ raw
251
+ else
252
+ raise "Expected GraphQL WebSocket message object, got #{raw.class}"
253
+ end
254
+ rescue JSON::ParserError => e
255
+ raise "Invalid GraphQL WebSocket message: #{e.message}"
256
+ end
257
+
258
+ def websocket_field(message, key)
259
+ message[key.to_s] || message[key.to_sym]
260
+ end
126
261
  end
262
+ # rubocop:enable Metrics/ClassLength
127
263
 
128
264
  # WebSocket test connection wrapper
129
265
  class WebSocketTestConnection
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spikard
4
- VERSION = '0.11.0'
4
+ VERSION = '0.12.0'
5
5
  end
data/lib/spikard.rb CHANGED
@@ -45,10 +45,6 @@ if defined?(Spikard::Native::TestClient) && !Spikard::Native::TestClient.method_
45
45
  end
46
46
  end
47
47
 
48
- # Convenience aliases and methods at top level
49
- module Spikard
50
- TestClient = Testing::TestClient
51
-
52
- # Handler wrapper utilities
53
- extend HandlerWrapper
54
- end
48
+ # Convenience aliases and methods
49
+ Spikard::TestClient = Spikard::Testing::TestClient
50
+ Spikard.extend Spikard::HandlerWrapper