spikard 0.13.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. checksums.yaml +4 -4
  2. data/Steepfile +6 -0
  3. data/ext/spikard_rb/extconf.rb +1 -2
  4. data/ext/spikard_rb/{Cargo.lock → native/Cargo.lock} +819 -424
  5. data/ext/spikard_rb/native/Cargo.toml +24 -0
  6. data/ext/spikard_rb/src/lib.rs +5366 -3
  7. data/lib/spikard/native.rb +86 -0
  8. data/lib/spikard/version.rb +6 -1
  9. data/lib/spikard.rb +8 -52
  10. data/lib/spikard_rb.so +0 -0
  11. data/sig/types.rbs +427 -0
  12. metadata +14 -243
  13. data/LICENSE +0 -1
  14. data/README.md +0 -285
  15. data/ext/spikard_rb/Cargo.toml +0 -17
  16. data/lib/spikard/app.rb +0 -458
  17. data/lib/spikard/background.rb +0 -58
  18. data/lib/spikard/config.rb +0 -506
  19. data/lib/spikard/converters.rb +0 -13
  20. data/lib/spikard/grpc.rb +0 -232
  21. data/lib/spikard/handler_wrapper.rb +0 -113
  22. data/lib/spikard/provide.rb +0 -315
  23. data/lib/spikard/response.rb +0 -198
  24. data/lib/spikard/schema.rb +0 -243
  25. data/lib/spikard/sse.rb +0 -111
  26. data/lib/spikard/streaming_response.rb +0 -44
  27. data/lib/spikard/testing.rb +0 -474
  28. data/lib/spikard/upload_file.rb +0 -131
  29. data/lib/spikard/websocket.rb +0 -59
  30. data/sig/spikard.rbs +0 -739
  31. data/vendor/crates/spikard-bindings-shared/Cargo.toml +0 -75
  32. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +0 -132
  33. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +0 -905
  34. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +0 -210
  35. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +0 -252
  36. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +0 -404
  37. data/vendor/crates/spikard-bindings-shared/src/grpc_metadata.rs +0 -199
  38. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +0 -252
  39. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +0 -829
  40. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +0 -587
  41. data/vendor/crates/spikard-bindings-shared/src/lib.rs +0 -33
  42. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +0 -298
  43. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +0 -594
  44. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +0 -743
  45. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +0 -944
  46. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +0 -260
  47. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +0 -369
  48. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +0 -192
  49. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +0 -383
  50. data/vendor/crates/spikard-bindings-shared/tests/full_coverage.rs +0 -459
  51. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +0 -280
  52. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +0 -669
  53. data/vendor/crates/spikard-core/Cargo.toml +0 -55
  54. data/vendor/crates/spikard-core/src/bindings/mod.rs +0 -3
  55. data/vendor/crates/spikard-core/src/bindings/response.rs +0 -130
  56. data/vendor/crates/spikard-core/src/debug.rs +0 -127
  57. data/vendor/crates/spikard-core/src/di/container.rs +0 -711
  58. data/vendor/crates/spikard-core/src/di/dependency.rs +0 -273
  59. data/vendor/crates/spikard-core/src/di/error.rs +0 -118
  60. data/vendor/crates/spikard-core/src/di/factory.rs +0 -548
  61. data/vendor/crates/spikard-core/src/di/graph.rs +0 -507
  62. data/vendor/crates/spikard-core/src/di/mod.rs +0 -192
  63. data/vendor/crates/spikard-core/src/di/resolved.rs +0 -428
  64. data/vendor/crates/spikard-core/src/di/value.rs +0 -282
  65. data/vendor/crates/spikard-core/src/errors.rs +0 -72
  66. data/vendor/crates/spikard-core/src/http.rs +0 -492
  67. data/vendor/crates/spikard-core/src/lib.rs +0 -29
  68. data/vendor/crates/spikard-core/src/lifecycle.rs +0 -1273
  69. data/vendor/crates/spikard-core/src/metadata.rs +0 -378
  70. data/vendor/crates/spikard-core/src/parameters.rs +0 -2546
  71. data/vendor/crates/spikard-core/src/problem.rs +0 -358
  72. data/vendor/crates/spikard-core/src/request_data.rs +0 -1146
  73. data/vendor/crates/spikard-core/src/router.rs +0 -530
  74. data/vendor/crates/spikard-core/src/schema_registry.rs +0 -197
  75. data/vendor/crates/spikard-core/src/type_hints.rs +0 -311
  76. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +0 -710
  77. data/vendor/crates/spikard-core/src/validation/mod.rs +0 -470
  78. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +0 -136
  79. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +0 -37
  80. data/vendor/crates/spikard-core/tests/error_mapper.rs +0 -761
  81. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +0 -106
  82. data/vendor/crates/spikard-core/tests/parameters_full.rs +0 -701
  83. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +0 -301
  84. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +0 -67
  85. data/vendor/crates/spikard-core/tests/validation_coverage.rs +0 -250
  86. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +0 -45
  87. data/vendor/crates/spikard-http/Cargo.toml +0 -82
  88. data/vendor/crates/spikard-http/examples/sse-notifications.rs +0 -148
  89. data/vendor/crates/spikard-http/examples/websocket-chat.rs +0 -92
  90. data/vendor/crates/spikard-http/src/auth.rs +0 -301
  91. data/vendor/crates/spikard-http/src/background.rs +0 -1859
  92. data/vendor/crates/spikard-http/src/bindings/mod.rs +0 -3
  93. data/vendor/crates/spikard-http/src/bindings/response.rs +0 -1
  94. data/vendor/crates/spikard-http/src/body_metadata.rs +0 -8
  95. data/vendor/crates/spikard-http/src/cors.rs +0 -1026
  96. data/vendor/crates/spikard-http/src/debug.rs +0 -128
  97. data/vendor/crates/spikard-http/src/di_handler.rs +0 -1672
  98. data/vendor/crates/spikard-http/src/grpc/framing.rs +0 -653
  99. data/vendor/crates/spikard-http/src/grpc/handler.rs +0 -1211
  100. data/vendor/crates/spikard-http/src/grpc/mod.rs +0 -556
  101. data/vendor/crates/spikard-http/src/grpc/service.rs +0 -706
  102. data/vendor/crates/spikard-http/src/grpc/streaming.rs +0 -319
  103. data/vendor/crates/spikard-http/src/handler_response.rs +0 -901
  104. data/vendor/crates/spikard-http/src/handler_trait.rs +0 -1015
  105. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +0 -290
  106. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +0 -502
  107. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +0 -648
  108. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +0 -60
  109. data/vendor/crates/spikard-http/src/jsonrpc/openrpc.rs +0 -325
  110. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +0 -1207
  111. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +0 -2262
  112. data/vendor/crates/spikard-http/src/lib.rs +0 -566
  113. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +0 -230
  114. data/vendor/crates/spikard-http/src/lifecycle.rs +0 -1193
  115. data/vendor/crates/spikard-http/src/middleware/mod.rs +0 -560
  116. data/vendor/crates/spikard-http/src/middleware/multipart.rs +0 -912
  117. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +0 -513
  118. data/vendor/crates/spikard-http/src/middleware/validation.rs +0 -768
  119. data/vendor/crates/spikard-http/src/openapi/mod.rs +0 -309
  120. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +0 -535
  121. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +0 -1363
  122. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +0 -667
  123. data/vendor/crates/spikard-http/src/query_parser.rs +0 -793
  124. data/vendor/crates/spikard-http/src/response.rs +0 -720
  125. data/vendor/crates/spikard-http/src/server/fast_router.rs +0 -186
  126. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +0 -1243
  127. data/vendor/crates/spikard-http/src/server/handler.rs +0 -1661
  128. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +0 -253
  129. data/vendor/crates/spikard-http/src/server/mod.rs +0 -1717
  130. data/vendor/crates/spikard-http/src/server/request_extraction.rs +0 -871
  131. data/vendor/crates/spikard-http/src/server/routing_factory.rs +0 -618
  132. data/vendor/crates/spikard-http/src/sse.rs +0 -1409
  133. data/vendor/crates/spikard-http/src/testing/form.rs +0 -52
  134. data/vendor/crates/spikard-http/src/testing/multipart.rs +0 -64
  135. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -825
  136. data/vendor/crates/spikard-http/src/testing.rs +0 -617
  137. data/vendor/crates/spikard-http/src/websocket.rs +0 -1477
  138. data/vendor/crates/spikard-http/tests/auth_integration.rs +0 -645
  139. data/vendor/crates/spikard-http/tests/background_behavior.rs +0 -832
  140. data/vendor/crates/spikard-http/tests/common/grpc_helpers.rs +0 -1012
  141. data/vendor/crates/spikard-http/tests/common/handlers.rs +0 -309
  142. data/vendor/crates/spikard-http/tests/common/mod.rs +0 -33
  143. data/vendor/crates/spikard-http/tests/common/test_builders.rs +0 -628
  144. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +0 -162
  145. data/vendor/crates/spikard-http/tests/di_integration.rs +0 -192
  146. data/vendor/crates/spikard-http/tests/doc_snippets.rs +0 -5
  147. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +0 -430
  148. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +0 -738
  149. data/vendor/crates/spikard-http/tests/grpc_error_handling_test.rs +0 -652
  150. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +0 -334
  151. data/vendor/crates/spikard-http/tests/grpc_metadata_test.rs +0 -532
  152. data/vendor/crates/spikard-http/tests/grpc_server_integration.rs +0 -495
  153. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +0 -975
  154. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +0 -1093
  155. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +0 -389
  156. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +0 -656
  157. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +0 -513
  158. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +0 -328
  159. data/vendor/crates/spikard-http/tests/server_config_builder.rs +0 -335
  160. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +0 -374
  161. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +0 -83
  162. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +0 -464
  163. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +0 -286
  164. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +0 -118
  165. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +0 -99
  166. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +0 -204
  167. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +0 -427
  168. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +0 -121
  169. data/vendor/crates/spikard-http/tests/sse_behavior.rs +0 -620
  170. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +0 -584
  171. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +0 -130
  172. data/vendor/crates/spikard-http/tests/test_client_requests.rs +0 -167
  173. data/vendor/crates/spikard-http/tests/testing_helpers.rs +0 -87
  174. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +0 -155
  175. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +0 -82
  176. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +0 -663
  177. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +0 -440
  178. data/vendor/crates/spikard-http/tests/websocket_integration.rs +0 -150
  179. data/vendor/crates/spikard-rb/Cargo.toml +0 -63
  180. data/vendor/crates/spikard-rb/build.rs +0 -200
  181. data/vendor/crates/spikard-rb/src/background.rs +0 -63
  182. data/vendor/crates/spikard-rb/src/config/mod.rs +0 -5
  183. data/vendor/crates/spikard-rb/src/config/server_config.rs +0 -401
  184. data/vendor/crates/spikard-rb/src/conversion.rs +0 -688
  185. data/vendor/crates/spikard-rb/src/di/builder.rs +0 -100
  186. data/vendor/crates/spikard-rb/src/di/mod.rs +0 -410
  187. data/vendor/crates/spikard-rb/src/grpc/handler.rs +0 -875
  188. data/vendor/crates/spikard-rb/src/grpc/mod.rs +0 -13
  189. data/vendor/crates/spikard-rb/src/gvl.rs +0 -80
  190. data/vendor/crates/spikard-rb/src/handler.rs +0 -699
  191. data/vendor/crates/spikard-rb/src/integration/mod.rs +0 -3
  192. data/vendor/crates/spikard-rb/src/lib.rs +0 -2268
  193. data/vendor/crates/spikard-rb/src/lifecycle.rs +0 -334
  194. data/vendor/crates/spikard-rb/src/metadata/mod.rs +0 -5
  195. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +0 -507
  196. data/vendor/crates/spikard-rb/src/request.rs +0 -439
  197. data/vendor/crates/spikard-rb/src/runtime/mod.rs +0 -5
  198. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +0 -368
  199. data/vendor/crates/spikard-rb/src/server.rs +0 -304
  200. data/vendor/crates/spikard-rb/src/sse.rs +0 -231
  201. data/vendor/crates/spikard-rb/src/testing/client.rs +0 -698
  202. data/vendor/crates/spikard-rb/src/testing/mod.rs +0 -7
  203. data/vendor/crates/spikard-rb/src/testing/sse.rs +0 -108
  204. data/vendor/crates/spikard-rb/src/testing/websocket.rs +0 -573
  205. data/vendor/crates/spikard-rb/src/websocket.rs +0 -521
  206. data/vendor/crates/spikard-rb-macros/Cargo.toml +0 -20
  207. data/vendor/crates/spikard-rb-macros/src/lib.rs +0 -51
@@ -1,474 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'timeout'
5
-
6
- module Spikard
7
- # Testing helpers that wrap the native Ruby extension.
8
- module Testing
9
- GRAPHQL_WS_MAX_CONTROL_MESSAGES = 32
10
- @open_test_clients = []
11
-
12
- module_function
13
-
14
- def create_test_client(app, config: nil)
15
- trace('create_test_client:start')
16
- ensure_native_test_client!
17
- config = resolve_test_config(app, config)
18
- native = build_native_test_client(app, config)
19
- trace('create_test_client:done')
20
- TestClient.new(native)
21
- end
22
-
23
- def ensure_native_test_client!
24
- return if defined?(Spikard::Native::TestClient)
25
-
26
- raise LoadError, 'Spikard native test client is not available. Build the native extension before running tests.'
27
- end
28
-
29
- def resolve_test_config(app, config)
30
- return config if config
31
-
32
- if app.instance_variable_defined?(:@__spikard_test_config)
33
- return app.instance_variable_get(:@__spikard_test_config)
34
- end
35
-
36
- Spikard::ServerConfig.new
37
- end
38
-
39
- def build_native_test_client(app, config)
40
- routes_json = app.normalized_routes_json
41
- handlers = app.handler_map.transform_keys(&:to_sym)
42
- ws_handlers = app.websocket_handlers || {}
43
- sse_producers = app.sse_producers || {}
44
- hooks = app.instance_variable_get(:@native_hooks)
45
- dependencies = app.dependencies || {}
46
- payload = { hooks: hooks, dependencies: dependencies }
47
- Spikard::Native::TestClient.new(routes_json, handlers, config, ws_handlers, sse_producers, payload)
48
- end
49
-
50
- def register_test_client(client)
51
- @open_test_clients << client
52
- client
53
- end
54
-
55
- def unregister_test_client(client)
56
- @open_test_clients.delete(client)
57
- client
58
- end
59
-
60
- def close_all_test_clients
61
- clients = @open_test_clients.dup
62
- @open_test_clients.clear
63
-
64
- clients.each do |client|
65
- client.close
66
- rescue StandardError
67
- nil
68
- end
69
-
70
- if defined?(Spikard::Native) &&
71
- Spikard::Native.respond_to?(:__shutdown_websocket_workers__, true)
72
- Spikard::Native.__shutdown_websocket_workers__
73
- end
74
- end
75
-
76
- # High level wrapper around the native test client.
77
- # rubocop:disable Metrics/ClassLength
78
- class TestClient
79
- def initialize(native)
80
- @native = native
81
- @closed = false
82
- Spikard::Testing.register_test_client(self)
83
- end
84
-
85
- # Factory method for creating test client from an app
86
- def self.new(app_or_native, config: nil)
87
- # If passed a native client directly, use it
88
- return super(app_or_native) if native_test_client_candidate?(app_or_native)
89
-
90
- # Otherwise, create test client from app
91
- Spikard::Testing.create_test_client(app_or_native, config: config)
92
- end
93
-
94
- def request(method, path, headers = nil, body = nil, **options)
95
- payload = build_request_payload(headers, body, options)
96
- payload = @native.request(method.to_s.upcase, path, payload)
97
- Response.new(payload)
98
- end
99
-
100
- def websocket(path)
101
- Testing.trace("websocket:start #{path}")
102
- native_ws = @native.websocket(path)
103
- Testing.trace("websocket:connected #{path}")
104
- WebSocketTestConnection.new(native_ws)
105
- end
106
-
107
- def sse(path)
108
- native_sse = @native.sse(path)
109
- SseStream.new(native_sse)
110
- end
111
-
112
- def graphql(query, variables = nil, operation_name = nil, path: '/graphql')
113
- payload = { query: query }
114
- payload[:variables] = variables unless variables.nil?
115
- payload[:operationName] = operation_name unless operation_name.nil?
116
- request('POST', path, nil, nil, json: payload)
117
- end
118
-
119
- def graphql_with_status(query, variables = nil, operation_name = nil, path: '/graphql')
120
- response = graphql(query, variables, operation_name, path: path)
121
- [response.status, response]
122
- end
123
-
124
- # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity
125
- # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
126
- def graphql_subscription(query, variables = nil, operation_name = nil, path: '/graphql')
127
- operation_id = 'spikard-subscription-1'
128
- payload = { query: query }
129
- payload[:variables] = variables unless variables.nil?
130
- payload[:operationName] = operation_name unless operation_name.nil?
131
-
132
- ws = websocket(path)
133
- ws.send_json({ 'type' => 'connection_init' })
134
-
135
- acknowledged = false
136
- GRAPHQL_WS_MAX_CONTROL_MESSAGES.times do
137
- message = websocket_protocol_message(ws.receive_json)
138
- message_type = websocket_field(message, :type)
139
-
140
- if message_type == 'connection_ack'
141
- acknowledged = true
142
- break
143
- end
144
-
145
- if message_type == 'ping'
146
- pong = { 'type' => 'pong' }
147
- pong['payload'] = websocket_field(message, :payload) if message.key?('payload') || message.key?(:payload)
148
- ws.send_json(pong)
149
- next
150
- end
151
-
152
- if %w[connection_error error].include?(message_type)
153
- raise "GraphQL subscription rejected during init: #{message}"
154
- end
155
- end
156
-
157
- raise 'No GraphQL connection_ack received' unless acknowledged
158
-
159
- ws.send_json({
160
- 'id' => operation_id,
161
- 'type' => 'subscribe',
162
- 'payload' => payload
163
- })
164
-
165
- event = nil
166
- errors = []
167
- complete_received = false
168
-
169
- GRAPHQL_WS_MAX_CONTROL_MESSAGES.times do
170
- message = websocket_protocol_message(ws.receive_json)
171
- message_type = websocket_field(message, :type)
172
- message_id = websocket_field(message, :id)
173
- id_matches = message_id.nil? || message_id == operation_id
174
-
175
- if message_type == 'next' && id_matches
176
- event = websocket_field(message, :payload)
177
- ws.send_json({ 'id' => operation_id, 'type' => 'complete' })
178
-
179
- begin
180
- maybe_complete = websocket_protocol_message(ws.receive_json)
181
- complete_type = websocket_field(maybe_complete, :type)
182
- complete_id = websocket_field(maybe_complete, :id)
183
- complete_received = complete_type == 'complete' && (complete_id.nil? || complete_id == operation_id)
184
- rescue StandardError
185
- # Some servers close immediately after client complete.
186
- end
187
- break
188
- end
189
-
190
- if message_type == 'error'
191
- errors << (websocket_field(message, :payload) || message)
192
- break
193
- end
194
-
195
- if message_type == 'complete' && id_matches
196
- complete_received = true
197
- break
198
- end
199
-
200
- next unless message_type == 'ping'
201
-
202
- pong = { 'type' => 'pong' }
203
- pong['payload'] = websocket_field(message, :payload) if message.key?('payload') || message.key?(:payload)
204
- ws.send_json(pong)
205
- end
206
-
207
- if event.nil? && errors.empty? && !complete_received
208
- raise 'No GraphQL subscription event received before timeout'
209
- end
210
-
211
- {
212
- 'operation_id' => operation_id,
213
- 'acknowledged' => true,
214
- 'event' => event,
215
- 'errors' => errors,
216
- 'complete_received' => complete_received
217
- }
218
- ensure
219
- ws&.close
220
- end
221
- # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
222
- # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity
223
-
224
- def close
225
- return if @closed
226
-
227
- @closed = true
228
- @native.close
229
- ensure
230
- Spikard::Testing.unregister_test_client(self)
231
- end
232
-
233
- %w[get post put patch delete head options trace].each do |verb|
234
- define_method(verb) do |path, headers = nil, body = nil, **options|
235
- request(verb.upcase, path, headers, body, **options)
236
- end
237
- end
238
-
239
- def self.native_test_client_candidate?(candidate)
240
- return true if candidate.is_a?(Spikard::Native::TestClient)
241
- return false if candidate.respond_to?(:normalized_routes_json)
242
-
243
- %i[request websocket sse close].any? { |method_name| candidate.respond_to?(method_name) }
244
- end
245
- private_class_method :native_test_client_candidate?
246
-
247
- private
248
-
249
- def build_request_payload(headers, body, options)
250
- payload = {}
251
- headers = options.delete(:headers) || headers
252
- cookies = options.delete(:cookies)
253
- query = options.delete(:query) || options.delete(:params)
254
-
255
- payload[:headers] = headers if headers
256
- payload[:cookies] = cookies if cookies
257
- payload[:query] = query if query
258
- payload.merge!(body_payload_from(options, body))
259
- payload
260
- end
261
-
262
- def body_payload_from(options, body)
263
- json = options.delete(:json)
264
- data = options.delete(:data)
265
- raw_body = options.delete(:raw_body)
266
- files = options.delete(:files)
267
- body_option = options.delete(:body)
268
-
269
- return explicit_body_payload(json, data, raw_body, files) if json || data || raw_body || files
270
-
271
- body_value = body_option.nil? ? body : body_option
272
- body_value.nil? ? {} : { json: body_value }
273
- end
274
-
275
- def explicit_body_payload(json, data, raw_body, files)
276
- payload = {}
277
- payload[:json] = json if json
278
- payload[:data] = data if data
279
- payload[:raw_body] = raw_body if raw_body
280
- payload[:files] = files if files
281
- payload
282
- end
283
-
284
- def websocket_protocol_message(raw)
285
- case raw
286
- when String
287
- parsed = JSON.parse(raw)
288
- raise 'Expected GraphQL WebSocket JSON object message' unless parsed.is_a?(Hash)
289
-
290
- parsed
291
- when Hash
292
- raw
293
- else
294
- raise "Expected GraphQL WebSocket message object, got #{raw.class}"
295
- end
296
- rescue JSON::ParserError => e
297
- raise "Invalid GraphQL WebSocket message: #{e.message}"
298
- end
299
-
300
- def websocket_field(message, key)
301
- message[key.to_s] || message[key.to_sym]
302
- end
303
- end
304
- # rubocop:enable Metrics/ClassLength
305
-
306
- # WebSocket test connection wrapper
307
- class WebSocketTestConnection
308
- def initialize(native_ws)
309
- @native_ws = native_ws
310
- end
311
-
312
- def send_text(text)
313
- Testing.trace('websocket:send_text')
314
- @native_ws.send_text(JSON.generate(text))
315
- end
316
-
317
- def send_json(obj)
318
- Testing.trace('websocket:send_json')
319
- @native_ws.send_json(obj)
320
- end
321
-
322
- def receive_text
323
- Testing.trace('websocket:receive_text')
324
- raw = with_timeout { @native_ws.receive_text }
325
- JSON.parse(raw)
326
- rescue JSON::ParserError
327
- raw
328
- end
329
-
330
- def receive_json
331
- Testing.trace('websocket:receive_json')
332
- with_timeout { @native_ws.receive_json }
333
- end
334
-
335
- def receive_bytes
336
- receive_text
337
- end
338
-
339
- def receive_message
340
- native_msg = @native_ws.receive_message
341
- WebSocketMessage.new(native_msg)
342
- end
343
-
344
- def close
345
- Testing.trace('websocket:close')
346
- @native_ws.close
347
- end
348
-
349
- private
350
-
351
- def with_timeout(&)
352
- timeout_ms = ENV.fetch('SPIKARD_RB_TEST_TIMEOUT_MS', nil)
353
- return yield unless timeout_ms
354
-
355
- Timeout.timeout(timeout_ms.to_f / 1000.0, &)
356
- end
357
- end
358
-
359
- # WebSocket message wrapper
360
- class WebSocketMessage
361
- def initialize(native_msg)
362
- @native_msg = native_msg
363
- end
364
-
365
- def as_text
366
- raw = @native_msg.as_text
367
- return unless raw
368
-
369
- JSON.parse(raw)
370
- rescue JSON::ParserError
371
- raw
372
- end
373
-
374
- def as_json
375
- @native_msg.as_json
376
- end
377
-
378
- def as_binary
379
- @native_msg.as_binary
380
- end
381
-
382
- def close?
383
- @native_msg.is_close
384
- end
385
- end
386
-
387
- # SSE stream wrapper
388
- class SseStream
389
- def initialize(native_sse)
390
- @native_sse = native_sse
391
- end
392
-
393
- def body
394
- @native_sse.body
395
- end
396
-
397
- def events
398
- parsed_chunks.map { |chunk| InlineSseEvent.new(chunk) }
399
- end
400
-
401
- def events_as_json
402
- parsed_chunks.filter_map do |chunk|
403
- JSON.parse(chunk)
404
- rescue JSON::ParserError
405
- nil
406
- end
407
- end
408
-
409
- private
410
-
411
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
412
- def parsed_chunks
413
- raw = body.to_s.gsub("\r\n", "\n")
414
- events = []
415
- current = []
416
-
417
- raw.each_line do |line|
418
- stripped = line.chomp
419
- if stripped.start_with?('data:')
420
- current << stripped[5..].strip
421
- elsif stripped.empty?
422
- unless current.empty?
423
- data = current.join("\n").strip
424
- events << data unless data.empty?
425
- current = []
426
- end
427
- end
428
- end
429
-
430
- unless current.empty?
431
- data = current.join("\n").strip
432
- events << data unless data.empty?
433
- end
434
-
435
- events
436
- end
437
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
438
- end
439
-
440
- # SSE event wrapper
441
- class SseEvent
442
- def initialize(native_event)
443
- @native_event = native_event
444
- end
445
-
446
- def data
447
- @native_event.data
448
- end
449
-
450
- def as_json
451
- @native_event.as_json
452
- end
453
- end
454
-
455
- # Lightweight wrapper for parsed SSE events backed by strings.
456
- class InlineSseEvent
457
- attr_reader :data
458
-
459
- def initialize(data)
460
- @data = data
461
- end
462
-
463
- def as_json
464
- JSON.parse(@data)
465
- end
466
- end
467
-
468
- def trace(message)
469
- return unless ENV['SPIKARD_RB_TEST_TRACE'] == '1'
470
-
471
- warn("[spikard-rb-test] #{message}")
472
- end
473
- end
474
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'stringio'
4
- require 'base64'
5
-
6
- module Spikard
7
- # File upload handling for multipart/form-data requests
8
- #
9
- # This class provides an interface for handling file uploads,
10
- # designed to be compatible with Rails patterns while optimized
11
- # for Spikard's Rust-backed request processing.
12
- #
13
- # @example
14
- # app.post('/upload') do |body|
15
- # file = body[:file] # UploadFile instance
16
- # content = file.read
17
- # {
18
- # filename: file.filename,
19
- # size: file.size,
20
- # content_type: file.content_type,
21
- # description: body[:description]
22
- # }
23
- # end
24
- class UploadFile
25
- # @return [String] Original filename from the client
26
- attr_reader :filename
27
-
28
- # @return [String] MIME type of the uploaded file
29
- attr_reader :content_type
30
-
31
- # @return [Integer] Size of the file in bytes
32
- attr_reader :size
33
-
34
- # @return [Hash<String, String>] Additional headers associated with this file field
35
- attr_reader :headers
36
-
37
- # Create a new UploadFile instance
38
- #
39
- # @param filename [String] Original filename from the client
40
- # @param content [String] File contents (may be base64 encoded)
41
- # @param content_type [String, nil] MIME type (defaults to "application/octet-stream")
42
- # @param size [Integer, nil] File size in bytes (computed from content if not provided)
43
- # @param headers [Hash<String, String>, nil] Additional headers from the multipart field
44
- # @param content_encoding [String, nil] Encoding type (e.g., "base64")
45
- def initialize(filename, content, content_type: nil, size: nil, headers: nil, content_encoding: nil)
46
- @filename = filename
47
- @content_type = content_type || 'application/octet-stream'
48
- @headers = headers || {}
49
-
50
- # Decode content if base64 encoded
51
- @content = if content_encoding == 'base64' || base64_encoded?(content)
52
- Base64.decode64(content)
53
- else
54
- content
55
- end
56
-
57
- @size = size || @content.bytesize
58
- @io = StringIO.new(@content)
59
- end
60
-
61
- # Read file contents
62
- #
63
- # @param size [Integer, nil] Number of bytes to read (nil for all remaining)
64
- # @return [String] File contents
65
- def read(size = nil)
66
- @io.read(size)
67
- end
68
-
69
- # Read file contents as text
70
- #
71
- # @param encoding [String] Character encoding (defaults to UTF-8)
72
- # @return [String] File contents as text
73
- def text(encoding: 'UTF-8')
74
- @content.force_encoding(encoding)
75
- end
76
-
77
- # Seek to a specific position in the file
78
- #
79
- # @param offset [Integer] Byte offset
80
- # @param whence [Integer] Position reference (IO::SEEK_SET, IO::SEEK_CUR, IO::SEEK_END)
81
- # @return [Integer] New position
82
- def seek(offset, whence = IO::SEEK_SET)
83
- @io.seek(offset, whence)
84
- end
85
-
86
- # Get current position in the file
87
- #
88
- # @return [Integer] Current byte offset
89
- def tell
90
- @io.tell
91
- end
92
- alias pos tell
93
-
94
- # Rewind to the beginning of the file
95
- #
96
- # @return [Integer] Always returns 0
97
- def rewind
98
- @io.rewind
99
- end
100
-
101
- # Close the file (no-op for StringIO-based implementation)
102
- #
103
- # @return [nil]
104
- def close
105
- @io.close
106
- end
107
-
108
- # Check if file is closed
109
- #
110
- # @return [Boolean]
111
- def closed?
112
- @io.closed?
113
- end
114
-
115
- # Get the raw content as a string
116
- #
117
- # @return [String] Raw file content
118
- attr_reader :content
119
-
120
- private
121
-
122
- # Check if a string appears to be base64 encoded
123
- #
124
- # @param str [String] String to check
125
- # @return [Boolean]
126
- def base64_encoded?(str)
127
- # Simple heuristic: check if string matches base64 pattern
128
- str.is_a?(String) && str.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
129
- end
130
- end
131
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Spikard
4
- # Base class for WebSocket message handlers.
5
- #
6
- # Implement this class to handle WebSocket connections and messages.
7
- #
8
- # @example
9
- # class ChatHandler < Spikard::WebSocketHandler
10
- # def handle_message(message)
11
- # # Echo message back
12
- # message
13
- # end
14
- #
15
- # def on_connect
16
- # puts "Client connected"
17
- # end
18
- #
19
- # def on_disconnect
20
- # puts "Client disconnected"
21
- # end
22
- # end
23
- #
24
- # app = Spikard::App.new
25
- #
26
- # app.websocket('/chat') do
27
- # ChatHandler.new
28
- # end
29
- #
30
- # app.run
31
- class WebSocketHandler
32
- # Handle an incoming WebSocket message.
33
- #
34
- # @param message [Hash] Parsed JSON message from the client
35
- # @return [Hash, nil] Optional response message to send back to the client.
36
- # Return nil to not send a response.
37
- def handle_message(message)
38
- raise NotImplementedError, "#{self.class.name} must implement #handle_message"
39
- end
40
-
41
- # Called when a client connects.
42
- #
43
- # Override this method to perform initialization when a client connects.
44
- #
45
- # @return [void]
46
- def on_connect
47
- # Optional hook - default implementation does nothing
48
- end
49
-
50
- # Called when a client disconnects.
51
- #
52
- # Override this method to perform cleanup when a client disconnects.
53
- #
54
- # @return [void]
55
- def on_disconnect
56
- # Optional hook - default implementation does nothing
57
- end
58
- end
59
- end