spikard 0.3.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +674 -659
  4. data/ext/spikard_rb/Cargo.toml +17 -17
  5. data/ext/spikard_rb/extconf.rb +13 -10
  6. data/ext/spikard_rb/src/lib.rs +6 -6
  7. data/lib/spikard/app.rb +405 -386
  8. data/lib/spikard/background.rb +27 -27
  9. data/lib/spikard/config.rb +396 -396
  10. data/lib/spikard/converters.rb +13 -13
  11. data/lib/spikard/handler_wrapper.rb +113 -113
  12. data/lib/spikard/provide.rb +214 -214
  13. data/lib/spikard/response.rb +173 -173
  14. data/lib/spikard/schema.rb +243 -243
  15. data/lib/spikard/sse.rb +111 -111
  16. data/lib/spikard/streaming_response.rb +44 -44
  17. data/lib/spikard/testing.rb +256 -221
  18. data/lib/spikard/upload_file.rb +131 -131
  19. data/lib/spikard/version.rb +5 -5
  20. data/lib/spikard/websocket.rb +59 -59
  21. data/lib/spikard.rb +43 -43
  22. data/sig/spikard.rbs +366 -366
  23. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  24. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
  25. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
  26. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  27. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  28. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
  29. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
  30. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
  31. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
  32. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
  33. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
  34. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  35. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
  36. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
  37. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
  38. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
  39. data/vendor/crates/spikard-core/Cargo.toml +40 -40
  40. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
  41. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
  42. data/vendor/crates/spikard-core/src/debug.rs +127 -63
  43. data/vendor/crates/spikard-core/src/di/container.rs +702 -726
  44. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
  45. data/vendor/crates/spikard-core/src/di/error.rs +118 -118
  46. data/vendor/crates/spikard-core/src/di/factory.rs +534 -538
  47. data/vendor/crates/spikard-core/src/di/graph.rs +506 -545
  48. data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
  49. data/vendor/crates/spikard-core/src/di/resolved.rs +405 -411
  50. data/vendor/crates/spikard-core/src/di/value.rs +281 -283
  51. data/vendor/crates/spikard-core/src/errors.rs +69 -39
  52. data/vendor/crates/spikard-core/src/http.rs +415 -153
  53. data/vendor/crates/spikard-core/src/lib.rs +29 -29
  54. data/vendor/crates/spikard-core/src/lifecycle.rs +1186 -422
  55. data/vendor/crates/spikard-core/src/metadata.rs +389 -0
  56. data/vendor/crates/spikard-core/src/parameters.rs +2525 -722
  57. data/vendor/crates/spikard-core/src/problem.rs +344 -310
  58. data/vendor/crates/spikard-core/src/request_data.rs +1154 -189
  59. data/vendor/crates/spikard-core/src/router.rs +510 -249
  60. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
  61. data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
  62. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +696 -0
  63. data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +457 -699
  64. data/vendor/crates/spikard-http/Cargo.toml +62 -68
  65. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
  66. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
  67. data/vendor/crates/spikard-http/src/auth.rs +296 -247
  68. data/vendor/crates/spikard-http/src/background.rs +1860 -249
  69. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
  70. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
  71. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
  72. data/vendor/crates/spikard-http/src/cors.rs +1005 -490
  73. data/vendor/crates/spikard-http/src/debug.rs +128 -63
  74. data/vendor/crates/spikard-http/src/di_handler.rs +1668 -423
  75. data/vendor/crates/spikard-http/src/handler_response.rs +901 -190
  76. data/vendor/crates/spikard-http/src/handler_trait.rs +838 -228
  77. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +290 -284
  78. data/vendor/crates/spikard-http/src/lib.rs +534 -529
  79. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +230 -149
  80. data/vendor/crates/spikard-http/src/lifecycle.rs +1193 -428
  81. data/vendor/crates/spikard-http/src/middleware/mod.rs +560 -285
  82. data/vendor/crates/spikard-http/src/middleware/multipart.rs +912 -86
  83. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +513 -147
  84. data/vendor/crates/spikard-http/src/middleware/validation.rs +768 -287
  85. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
  86. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -190
  87. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1363 -308
  88. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +665 -195
  89. data/vendor/crates/spikard-http/src/query_parser.rs +793 -369
  90. data/vendor/crates/spikard-http/src/response.rs +720 -399
  91. data/vendor/crates/spikard-http/src/server/handler.rs +1650 -87
  92. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +234 -98
  93. data/vendor/crates/spikard-http/src/server/mod.rs +1593 -805
  94. data/vendor/crates/spikard-http/src/server/request_extraction.rs +789 -119
  95. data/vendor/crates/spikard-http/src/server/routing_factory.rs +629 -0
  96. data/vendor/crates/spikard-http/src/sse.rs +1409 -447
  97. data/vendor/crates/spikard-http/src/testing/form.rs +52 -14
  98. data/vendor/crates/spikard-http/src/testing/multipart.rs +64 -60
  99. data/vendor/crates/spikard-http/src/testing/test_client.rs +311 -285
  100. data/vendor/crates/spikard-http/src/testing.rs +406 -377
  101. data/vendor/crates/spikard-http/src/websocket.rs +1404 -324
  102. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
  103. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
  104. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
  105. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
  106. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
  107. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
  108. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
  109. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
  110. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
  111. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
  112. data/vendor/crates/spikard-rb/Cargo.toml +48 -42
  113. data/vendor/crates/spikard-rb/build.rs +199 -8
  114. data/vendor/crates/spikard-rb/src/background.rs +63 -63
  115. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  116. data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +285 -294
  117. data/vendor/crates/spikard-rb/src/conversion.rs +554 -453
  118. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
  119. data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +375 -409
  120. data/vendor/crates/spikard-rb/src/handler.rs +618 -625
  121. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  122. data/vendor/crates/spikard-rb/src/lib.rs +1806 -2771
  123. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -274
  124. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  125. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +442 -0
  126. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  127. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
  128. data/vendor/crates/spikard-rb/src/server.rs +305 -283
  129. data/vendor/crates/spikard-rb/src/sse.rs +231 -231
  130. data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +538 -404
  131. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  132. data/vendor/crates/spikard-rb/src/{test_sse.rs → testing/sse.rs} +143 -143
  133. data/vendor/crates/spikard-rb/src/testing/websocket.rs +608 -0
  134. data/vendor/crates/spikard-rb/src/websocket.rs +377 -233
  135. metadata +60 -13
  136. data/vendor/crates/spikard-http/src/parameters.rs +0 -1
  137. data/vendor/crates/spikard-http/src/problem.rs +0 -1
  138. data/vendor/crates/spikard-http/src/router.rs +0 -1
  139. data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
  140. data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
  141. data/vendor/crates/spikard-http/src/validation.rs +0 -1
  142. data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
data/lib/spikard/app.rb CHANGED
@@ -1,386 +1,405 @@
1
- # frozen_string_literal: true
2
-
3
- module Spikard
4
- RouteEntry = Struct.new(:metadata, :handler)
5
-
6
- # Lifecycle hooks support for Spikard applications
7
- module LifecycleHooks
8
- # Register an onRequest lifecycle hook
9
- #
10
- # Runs before routing. Can inspect/modify the request or short-circuit with a response.
11
- #
12
- # @param hook [Proc] A proc that receives a request and returns either:
13
- # - The (possibly modified) request to continue processing
14
- # - A Response object to short-circuit the request pipeline
15
- # @return [Proc] The hook proc (for chaining)
16
- #
17
- # @example
18
- # app.on_request do |request|
19
- # puts "Request: #{request.method} #{request.path}"
20
- # request
21
- # end
22
- def on_request(&hook)
23
- native_hooks.add_on_request(hook)
24
- hook
25
- end
26
-
27
- # Register a preValidation lifecycle hook
28
- #
29
- # Runs after routing but before validation. Useful for rate limiting.
30
- #
31
- # @param hook [Proc] A proc that receives a request and returns either:
32
- # - The (possibly modified) request to continue processing
33
- # - A Response object to short-circuit the request pipeline
34
- # @return [Proc] The hook proc (for chaining)
35
- #
36
- # @example
37
- # app.pre_validation do |request|
38
- # if too_many_requests?
39
- # Spikard::Response.new(content: { error: "Rate limit exceeded" }, status_code: 429)
40
- # else
41
- # request
42
- # end
43
- # end
44
- def pre_validation(&hook)
45
- native_hooks.add_pre_validation(hook)
46
- hook
47
- end
48
-
49
- # Register a preHandler lifecycle hook
50
- #
51
- # Runs after validation but before the handler. Ideal for authentication/authorization.
52
- #
53
- # @param hook [Proc] A proc that receives a request and returns either:
54
- # - The (possibly modified) request to continue processing
55
- # - A Response object to short-circuit the request pipeline
56
- # @return [Proc] The hook proc (for chaining)
57
- #
58
- # @example
59
- # app.pre_handler do |request|
60
- # if invalid_token?(request.headers['Authorization'])
61
- # Spikard::Response.new(content: { error: "Unauthorized" }, status_code: 401)
62
- # else
63
- # request
64
- # end
65
- # end
66
- def pre_handler(&hook)
67
- native_hooks.add_pre_handler(hook)
68
- hook
69
- end
70
-
71
- # Register an onResponse lifecycle hook
72
- #
73
- # Runs after the handler executes. Can modify the response.
74
- #
75
- # @param hook [Proc] A proc that receives a response and returns the (possibly modified) response
76
- # @return [Proc] The hook proc (for chaining)
77
- #
78
- # @example
79
- # app.on_response do |response|
80
- # response.headers['X-Frame-Options'] = 'DENY'
81
- # response
82
- # end
83
- def on_response(&hook)
84
- native_hooks.add_on_response(hook)
85
- hook
86
- end
87
-
88
- # Register an onError lifecycle hook
89
- #
90
- # Runs when an error occurs. Can customize error responses.
91
- #
92
- # @param hook [Proc] A proc that receives an error response and returns a (possibly modified) response
93
- # @return [Proc] The hook proc (for chaining)
94
- #
95
- # @example
96
- # app.on_error do |response|
97
- # response.headers['Content-Type'] = 'application/json'
98
- # response
99
- # end
100
- def on_error(&hook)
101
- native_hooks.add_on_error(hook)
102
- hook
103
- end
104
-
105
- private
106
-
107
- def native_hooks
108
- raise 'Spikard native lifecycle registry unavailable' unless defined?(@native_hooks) && @native_hooks
109
-
110
- @native_hooks
111
- end
112
- end
113
-
114
- # Collects route metadata so the Rust engine can execute handlers.
115
- # rubocop:disable Metrics/ClassLength
116
- class App
117
- include LifecycleHooks
118
- include ProvideSupport
119
-
120
- HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
121
- SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors
122
- body_param_name].freeze
123
-
124
- attr_reader :routes
125
-
126
- def initialize
127
- @routes = []
128
- @websocket_handlers = {}
129
- @sse_producers = {}
130
- @native_hooks = Spikard::Native::LifecycleRegistry.new
131
- @native_dependencies = Spikard::Native::DependencyRegistry.new
132
- end
133
-
134
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
135
- def register_route(method, path, handler_name: nil, **options, &block)
136
- method = method.to_s
137
- path = path.to_s
138
- handler_name = handler_name&.to_s
139
- validate_route_arguments!(block, options)
140
- metadata = if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_route_metadata)
141
- Spikard::Native.build_route_metadata(
142
- method,
143
- path,
144
- handler_name,
145
- options[:request_schema],
146
- options[:response_schema],
147
- options[:parameter_schema],
148
- options[:file_params],
149
- options.fetch(:is_async, false),
150
- options[:cors],
151
- options[:body_param_name]&.to_s,
152
- block
153
- )
154
- else
155
- handler_name ||= default_handler_name(method, path)
156
-
157
- # Extract handler dependencies from block parameters
158
- handler_dependencies = extract_handler_dependencies(block)
159
-
160
- build_metadata(method, path, handler_name, options, handler_dependencies)
161
- end
162
-
163
- @routes << RouteEntry.new(metadata, block)
164
- block
165
- end
166
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
167
-
168
- HTTP_METHODS.each do |verb|
169
- define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
170
- register_route(verb, path, handler_name: handler_name, **options, &block)
171
- end
172
- end
173
-
174
- def route_metadata
175
- @routes.map(&:metadata)
176
- end
177
-
178
- def handler_map
179
- map = {}
180
- @routes.each do |entry|
181
- name = entry.metadata[:handler_name]
182
- # Pass raw handler - DI resolution happens in Rust layer
183
- map[name] = entry.handler
184
- end
185
- map
186
- end
187
-
188
- def normalized_routes_json
189
- json = JSON.generate(route_metadata)
190
- if defined?(Spikard::Native) && Spikard::Native.respond_to?(:normalize_route_metadata)
191
- Spikard::Native.normalize_route_metadata(json)
192
- else
193
- json
194
- end
195
- end
196
-
197
- def default_handler_name(method, path)
198
- normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_')
199
- # ReDoS mitigation: use bounded quantifier {1,100} instead of + to prevent
200
- # polynomial time complexity with excessive trailing underscores
201
- normalized_path = normalized_path.sub(/^_{1,100}/, '').sub(/_{1,100}$/, '')
202
- normalized_path = 'root' if normalized_path.empty?
203
- "#{method.to_s.downcase}_#{normalized_path}"
204
- end
205
-
206
- # Register a WebSocket endpoint
207
- #
208
- # @param path [String] URL path for the WebSocket endpoint
209
- # @yield Factory block that returns a WebSocketHandler instance
210
- # @return [Proc] The factory block (for chaining)
211
- #
212
- # @example
213
- # app.websocket('/chat') do
214
- # ChatHandler.new
215
- # end
216
- def websocket(path, _handler_name: nil, **_options, &factory)
217
- raise ArgumentError, 'block required for WebSocket handler factory' unless factory
218
-
219
- @websocket_handlers[path] = factory
220
- factory
221
- end
222
-
223
- # Register a Server-Sent Events endpoint
224
- #
225
- # @param path [String] URL path for the SSE endpoint
226
- # @yield Factory block that returns a SseEventProducer instance
227
- # @return [Proc] The factory block (for chaining)
228
- #
229
- # @example
230
- # app.sse('/notifications') do
231
- # NotificationProducer.new
232
- # end
233
- def sse(path, _handler_name: nil, **_options, &factory)
234
- raise ArgumentError, 'block required for SSE producer factory' unless factory
235
-
236
- @sse_producers[path] = factory
237
- factory
238
- end
239
-
240
- # Get all registered WebSocket handlers
241
- #
242
- # @return [Hash] Dictionary mapping paths to handler factory blocks
243
- def websocket_handlers
244
- @websocket_handlers.dup
245
- end
246
-
247
- # Get all registered SSE producers
248
- #
249
- # @return [Hash] Dictionary mapping paths to producer factory blocks
250
- def sse_producers
251
- @sse_producers.dup
252
- end
253
-
254
- # Run the Spikard server with the given configuration
255
- #
256
- # @param config [ServerConfig, Hash, nil] Server configuration
257
- # Can be a ServerConfig object, a Hash with configuration keys, or nil to use defaults.
258
- # If a Hash is provided, it will be converted to a ServerConfig.
259
- # For backward compatibility, also accepts host: and port: keyword arguments.
260
- #
261
- # @example With ServerConfig
262
- # config = Spikard::ServerConfig.new(
263
- # host: '0.0.0.0',
264
- # port: 8080,
265
- # compression: Spikard::CompressionConfig.new(quality: 9)
266
- # )
267
- # app.run(config: config)
268
- #
269
- # @example With Hash
270
- # app.run(config: { host: '0.0.0.0', port: 8080 })
271
- #
272
- # @example Backward compatible (deprecated)
273
- # app.run(host: '0.0.0.0', port: 8000)
274
- # rubocop:disable Metrics/MethodLength
275
- def run(config: nil, host: nil, port: nil)
276
- require 'json'
277
-
278
- # Backward compatibility: if host/port are provided directly, create a config
279
- if config.nil? && (host || port)
280
- config = ServerConfig.new(
281
- host: host || '127.0.0.1',
282
- port: port || 8000
283
- )
284
- elsif config.nil?
285
- config = ServerConfig.new
286
- elsif config.is_a?(Hash)
287
- config = ServerConfig.new(**config)
288
- end
289
-
290
- routes_json = normalized_routes_json
291
-
292
- # Get handler map
293
- handlers = handler_map
294
-
295
- # Get lifecycle hooks
296
- hooks = @native_hooks
297
-
298
- # Get WebSocket handlers and SSE producers
299
- ws_handlers = websocket_handlers
300
- sse_prods = sse_producers
301
-
302
- # Get dependencies for DI
303
- deps = @native_dependencies
304
-
305
- # Call the Rust extension's run_server function
306
- Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods, deps)
307
-
308
- # Keep Ruby process alive while server runs
309
- sleep
310
- rescue LoadError => e
311
- raise 'Failed to load Spikard extension. ' \
312
- "Build it with: task build:ruby\n#{e.message}"
313
- end
314
- # rubocop:enable Metrics/MethodLength
315
-
316
- private
317
-
318
- def normalize_path(path)
319
- # Preserve trailing slash for consistent routing
320
- has_trailing_slash = path.end_with?('/')
321
-
322
- segments = path.split('/').map do |segment|
323
- if segment.start_with?(':') && segment.length > 1
324
- "{#{segment[1..]}}"
325
- else
326
- segment
327
- end
328
- end
329
-
330
- normalized = segments.join('/')
331
- # Restore trailing slash if original path had one
332
- has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
333
- end
334
-
335
- def validate_route_arguments!(block, options)
336
- raise ArgumentError, 'block required for route handler' unless block
337
-
338
- unknown_keys = options.keys - SUPPORTED_OPTIONS
339
- return if unknown_keys.empty?
340
-
341
- raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
342
- end
343
-
344
- def extract_handler_dependencies(block)
345
- # Get the block's parameters
346
- params = block.parameters
347
-
348
- # Extract keyword parameters (dependencies)
349
- # Parameters come in the format [:req/:opt/:keyreq/:key, :param_name]
350
- # :keyreq and :key are keyword parameters (required and optional)
351
- dependencies = []
352
-
353
- params.each do |param_type, param_name|
354
- # Skip the request parameter (usually first positional param)
355
- # Only collect keyword parameters
356
- next unless %i[keyreq key].include?(param_type)
357
-
358
- dep_name = param_name.to_s
359
- # Collect ALL keyword parameters, not just registered ones
360
- # This allows the DI system to validate missing dependencies
361
- dependencies << dep_name
362
- end
363
-
364
- dependencies
365
- end
366
-
367
- def build_metadata(method, path, handler_name, options, handler_dependencies)
368
- base = {
369
- method: method,
370
- path: normalize_path(path),
371
- handler_name: handler_name,
372
- is_async: options.fetch(:is_async, false)
373
- }
374
-
375
- # Add handler_dependencies if present
376
- base[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
377
-
378
- SUPPORTED_OPTIONS.each_with_object(base) do |key, metadata|
379
- next if key == :is_async || !options.key?(key)
380
-
381
- metadata[key] = options[key]
382
- end
383
- end
384
- end
385
- # rubocop:enable Metrics/ClassLength
386
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ RouteEntry = Struct.new(:metadata, :handler)
5
+
6
+ # Lifecycle hooks support for Spikard applications
7
+ module LifecycleHooks
8
+ # Register an onRequest lifecycle hook
9
+ #
10
+ # Runs before routing. Can inspect/modify the request or short-circuit with a response.
11
+ #
12
+ # @param hook [Proc] A proc that receives a request and returns either:
13
+ # - The (possibly modified) request to continue processing
14
+ # - A Response object to short-circuit the request pipeline
15
+ # @return [Proc] The hook proc (for chaining)
16
+ #
17
+ # @example
18
+ # app.on_request do |request|
19
+ # puts "Request: #{request.method} #{request.path}"
20
+ # request
21
+ # end
22
+ def on_request(&hook)
23
+ native_hooks.add_on_request(hook)
24
+ hook
25
+ end
26
+
27
+ # Register a preValidation lifecycle hook
28
+ #
29
+ # Runs after routing but before validation. Useful for rate limiting.
30
+ #
31
+ # @param hook [Proc] A proc that receives a request and returns either:
32
+ # - The (possibly modified) request to continue processing
33
+ # - A Response object to short-circuit the request pipeline
34
+ # @return [Proc] The hook proc (for chaining)
35
+ #
36
+ # @example
37
+ # app.pre_validation do |request|
38
+ # if too_many_requests?
39
+ # Spikard::Response.new(content: { error: "Rate limit exceeded" }, status_code: 429)
40
+ # else
41
+ # request
42
+ # end
43
+ # end
44
+ def pre_validation(&hook)
45
+ native_hooks.add_pre_validation(hook)
46
+ hook
47
+ end
48
+
49
+ # Register a preHandler lifecycle hook
50
+ #
51
+ # Runs after validation but before the handler. Ideal for authentication/authorization.
52
+ #
53
+ # @param hook [Proc] A proc that receives a request and returns either:
54
+ # - The (possibly modified) request to continue processing
55
+ # - A Response object to short-circuit the request pipeline
56
+ # @return [Proc] The hook proc (for chaining)
57
+ #
58
+ # @example
59
+ # app.pre_handler do |request|
60
+ # if invalid_token?(request.headers['Authorization'])
61
+ # Spikard::Response.new(content: { error: "Unauthorized" }, status_code: 401)
62
+ # else
63
+ # request
64
+ # end
65
+ # end
66
+ def pre_handler(&hook)
67
+ native_hooks.add_pre_handler(hook)
68
+ hook
69
+ end
70
+
71
+ # Register an onResponse lifecycle hook
72
+ #
73
+ # Runs after the handler executes. Can modify the response.
74
+ #
75
+ # @param hook [Proc] A proc that receives a response and returns the (possibly modified) response
76
+ # @return [Proc] The hook proc (for chaining)
77
+ #
78
+ # @example
79
+ # app.on_response do |response|
80
+ # response.headers['X-Frame-Options'] = 'DENY'
81
+ # response
82
+ # end
83
+ def on_response(&hook)
84
+ native_hooks.add_on_response(hook)
85
+ hook
86
+ end
87
+
88
+ # Register an onError lifecycle hook
89
+ #
90
+ # Runs when an error occurs. Can customize error responses.
91
+ #
92
+ # @param hook [Proc] A proc that receives an error response and returns a (possibly modified) response
93
+ # @return [Proc] The hook proc (for chaining)
94
+ #
95
+ # @example
96
+ # app.on_error do |response|
97
+ # response.headers['Content-Type'] = 'application/json'
98
+ # response
99
+ # end
100
+ def on_error(&hook)
101
+ native_hooks.add_on_error(hook)
102
+ hook
103
+ end
104
+
105
+ private
106
+
107
+ def native_hooks
108
+ raise 'Spikard native lifecycle registry unavailable' unless defined?(@native_hooks) && @native_hooks
109
+
110
+ @native_hooks
111
+ end
112
+ end
113
+
114
+ # Collects route metadata so the Rust engine can execute handlers.
115
+ # rubocop:disable Metrics/ClassLength
116
+ class App
117
+ include LifecycleHooks
118
+ include ProvideSupport
119
+
120
+ HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
121
+ SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors
122
+ body_param_name jsonrpc_method].freeze
123
+
124
+ attr_reader :routes
125
+
126
+ def initialize
127
+ @routes = []
128
+ @websocket_handlers = {}
129
+ @sse_producers = {}
130
+ @native_hooks = Spikard::Native::LifecycleRegistry.new
131
+ @native_dependencies = Spikard::Native::DependencyRegistry.new
132
+ end
133
+
134
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
135
+ def register_route(method, path, handler_name: nil, **options, &block)
136
+ method = method.to_s
137
+ path = path.to_s
138
+ handler_name = handler_name&.to_s
139
+ validate_route_arguments!(block, options)
140
+ metadata = if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_route_metadata)
141
+ begin
142
+ Spikard::Native.build_route_metadata(
143
+ method,
144
+ path,
145
+ handler_name,
146
+ options[:request_schema],
147
+ options[:response_schema],
148
+ options[:parameter_schema],
149
+ options[:file_params],
150
+ options.fetch(:is_async, false),
151
+ options[:cors],
152
+ options[:body_param_name]&.to_s,
153
+ options[:jsonrpc_method],
154
+ block
155
+ )
156
+ rescue ArgumentError => e
157
+ raise unless e.message.include?('wrong number of arguments')
158
+
159
+ Spikard::Native.build_route_metadata(
160
+ method,
161
+ path,
162
+ handler_name,
163
+ options[:request_schema],
164
+ options[:response_schema],
165
+ options[:parameter_schema],
166
+ options[:file_params],
167
+ options.fetch(:is_async, false),
168
+ options[:cors],
169
+ options[:body_param_name]&.to_s,
170
+ block
171
+ )
172
+ end
173
+ else
174
+ handler_name ||= default_handler_name(method, path)
175
+
176
+ # Extract handler dependencies from block parameters
177
+ handler_dependencies = extract_handler_dependencies(block)
178
+
179
+ build_metadata(method, path, handler_name, options, handler_dependencies)
180
+ end
181
+
182
+ @routes << RouteEntry.new(metadata, block)
183
+ block
184
+ end
185
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
186
+
187
+ HTTP_METHODS.each do |verb|
188
+ define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
189
+ register_route(verb, path, handler_name: handler_name, **options, &block)
190
+ end
191
+ end
192
+
193
+ def route_metadata
194
+ @routes.map(&:metadata)
195
+ end
196
+
197
+ def handler_map
198
+ map = {}
199
+ @routes.each do |entry|
200
+ name = entry.metadata[:handler_name]
201
+ # Pass raw handler - DI resolution happens in Rust layer
202
+ map[name] = entry.handler
203
+ end
204
+ map
205
+ end
206
+
207
+ def normalized_routes_json
208
+ json = JSON.generate(route_metadata)
209
+ if defined?(Spikard::Native) && Spikard::Native.respond_to?(:normalize_route_metadata)
210
+ Spikard::Native.normalize_route_metadata(json)
211
+ else
212
+ json
213
+ end
214
+ end
215
+
216
+ def default_handler_name(method, path)
217
+ normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_')
218
+ # ReDoS mitigation: use bounded quantifier {1,100} instead of + to prevent
219
+ # polynomial time complexity with excessive trailing underscores
220
+ normalized_path = normalized_path.sub(/^_{1,100}/, '').sub(/_{1,100}$/, '')
221
+ normalized_path = 'root' if normalized_path.empty?
222
+ "#{method.to_s.downcase}_#{normalized_path}"
223
+ end
224
+
225
+ # Register a WebSocket endpoint
226
+ #
227
+ # @param path [String] URL path for the WebSocket endpoint
228
+ # @yield Factory block that returns a WebSocketHandler instance
229
+ # @return [Proc] The factory block (for chaining)
230
+ #
231
+ # @example
232
+ # app.websocket('/chat') do
233
+ # ChatHandler.new
234
+ # end
235
+ def websocket(path, _handler_name: nil, **_options, &factory)
236
+ raise ArgumentError, 'block required for WebSocket handler factory' unless factory
237
+
238
+ @websocket_handlers[path] = factory
239
+ factory
240
+ end
241
+
242
+ # Register a Server-Sent Events endpoint
243
+ #
244
+ # @param path [String] URL path for the SSE endpoint
245
+ # @yield Factory block that returns a SseEventProducer instance
246
+ # @return [Proc] The factory block (for chaining)
247
+ #
248
+ # @example
249
+ # app.sse('/notifications') do
250
+ # NotificationProducer.new
251
+ # end
252
+ def sse(path, _handler_name: nil, **_options, &factory)
253
+ raise ArgumentError, 'block required for SSE producer factory' unless factory
254
+
255
+ @sse_producers[path] = factory
256
+ factory
257
+ end
258
+
259
+ # Get all registered WebSocket handlers
260
+ #
261
+ # @return [Hash] Dictionary mapping paths to handler factory blocks
262
+ def websocket_handlers
263
+ @websocket_handlers.dup
264
+ end
265
+
266
+ # Get all registered SSE producers
267
+ #
268
+ # @return [Hash] Dictionary mapping paths to producer factory blocks
269
+ def sse_producers
270
+ @sse_producers.dup
271
+ end
272
+
273
+ # Run the Spikard server with the given configuration
274
+ #
275
+ # @param config [ServerConfig, Hash, nil] Server configuration
276
+ # Can be a ServerConfig object, a Hash with configuration keys, or nil to use defaults.
277
+ # If a Hash is provided, it will be converted to a ServerConfig.
278
+ # For backward compatibility, also accepts host: and port: keyword arguments.
279
+ #
280
+ # @example With ServerConfig
281
+ # config = Spikard::ServerConfig.new(
282
+ # host: '0.0.0.0',
283
+ # port: 8080,
284
+ # compression: Spikard::CompressionConfig.new(quality: 9)
285
+ # )
286
+ # app.run(config: config)
287
+ #
288
+ # @example With Hash
289
+ # app.run(config: { host: '0.0.0.0', port: 8080 })
290
+ #
291
+ # @example Backward compatible (deprecated)
292
+ # app.run(host: '0.0.0.0', port: 8000)
293
+ # rubocop:disable Metrics/MethodLength
294
+ def run(config: nil, host: nil, port: nil)
295
+ require 'json'
296
+
297
+ # Backward compatibility: if host/port are provided directly, create a config
298
+ if config.nil? && (host || port)
299
+ config = ServerConfig.new(
300
+ host: host || '127.0.0.1',
301
+ port: port || 8000
302
+ )
303
+ elsif config.nil?
304
+ config = ServerConfig.new
305
+ elsif config.is_a?(Hash)
306
+ config = ServerConfig.new(**config)
307
+ end
308
+
309
+ routes_json = normalized_routes_json
310
+
311
+ # Get handler map
312
+ handlers = handler_map
313
+
314
+ # Get lifecycle hooks
315
+ hooks = @native_hooks
316
+
317
+ # Get WebSocket handlers and SSE producers
318
+ ws_handlers = websocket_handlers
319
+ sse_prods = sse_producers
320
+
321
+ # Get dependencies for DI
322
+ deps = @native_dependencies
323
+
324
+ # Call the Rust extension's run_server function
325
+ Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods, deps)
326
+
327
+ # Keep Ruby process alive while server runs
328
+ sleep
329
+ rescue LoadError => e
330
+ raise 'Failed to load Spikard extension. ' \
331
+ "Build it with: task build:ruby\n#{e.message}"
332
+ end
333
+ # rubocop:enable Metrics/MethodLength
334
+
335
+ private
336
+
337
+ def normalize_path(path)
338
+ # Preserve trailing slash for consistent routing
339
+ has_trailing_slash = path.end_with?('/')
340
+
341
+ segments = path.split('/').map do |segment|
342
+ if segment.start_with?(':') && segment.length > 1
343
+ "{#{segment[1..]}}"
344
+ else
345
+ segment
346
+ end
347
+ end
348
+
349
+ normalized = segments.join('/')
350
+ # Restore trailing slash if original path had one
351
+ has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
352
+ end
353
+
354
+ def validate_route_arguments!(block, options)
355
+ raise ArgumentError, 'block required for route handler' unless block
356
+
357
+ unknown_keys = options.keys - SUPPORTED_OPTIONS
358
+ return if unknown_keys.empty?
359
+
360
+ raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
361
+ end
362
+
363
+ def extract_handler_dependencies(block)
364
+ # Get the block's parameters
365
+ params = block.parameters
366
+
367
+ # Extract keyword parameters (dependencies)
368
+ # Parameters come in the format [:req/:opt/:keyreq/:key, :param_name]
369
+ # :keyreq and :key are keyword parameters (required and optional)
370
+ dependencies = []
371
+
372
+ params.each do |param_type, param_name|
373
+ # Skip the request parameter (usually first positional param)
374
+ # Only collect keyword parameters
375
+ next unless %i[keyreq key].include?(param_type)
376
+
377
+ dep_name = param_name.to_s
378
+ # Collect ALL keyword parameters, not just registered ones
379
+ # This allows the DI system to validate missing dependencies
380
+ dependencies << dep_name
381
+ end
382
+
383
+ dependencies
384
+ end
385
+
386
+ def build_metadata(method, path, handler_name, options, handler_dependencies)
387
+ base = {
388
+ method: method,
389
+ path: normalize_path(path),
390
+ handler_name: handler_name,
391
+ is_async: options.fetch(:is_async, false)
392
+ }
393
+
394
+ # Add handler_dependencies if present
395
+ base[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
396
+
397
+ SUPPORTED_OPTIONS.each_with_object(base) do |key, metadata|
398
+ next if key == :is_async || !options.key?(key)
399
+
400
+ metadata[key] = options[key]
401
+ end
402
+ end
403
+ end
404
+ # rubocop:enable Metrics/ClassLength
405
+ end