spikard 0.2.1 → 0.2.5

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +626 -626
  4. data/ext/spikard_rb/Cargo.toml +17 -17
  5. data/ext/spikard_rb/extconf.rb +10 -10
  6. data/ext/spikard_rb/src/lib.rs +6 -6
  7. data/lib/spikard/app.rb +374 -374
  8. data/lib/spikard/background.rb +27 -27
  9. data/lib/spikard/config.rb +396 -396
  10. data/lib/spikard/converters.rb +85 -85
  11. data/lib/spikard/handler_wrapper.rb +116 -116
  12. data/lib/spikard/provide.rb +228 -228
  13. data/lib/spikard/response.rb +109 -109
  14. data/lib/spikard/schema.rb +243 -243
  15. data/lib/spikard/sse.rb +111 -111
  16. data/lib/spikard/streaming_response.rb +21 -21
  17. data/lib/spikard/testing.rb +221 -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 +349 -349
  23. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  24. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  25. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  26. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  27. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  28. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  29. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  30. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  31. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  32. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  34. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  35. data/vendor/crates/spikard-core/src/http.rs +153 -0
  36. data/vendor/crates/spikard-core/src/lib.rs +28 -0
  37. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  38. data/vendor/crates/spikard-core/src/parameters.rs +719 -0
  39. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  40. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  41. data/vendor/crates/spikard-core/src/router.rs +249 -0
  42. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  43. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  44. data/vendor/crates/spikard-core/src/validation.rs +699 -0
  45. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  46. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  47. data/vendor/crates/spikard-http/src/background.rs +249 -0
  48. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  49. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  50. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  51. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  52. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  53. data/vendor/crates/spikard-http/src/di_handler.rs +423 -0
  54. data/vendor/crates/spikard-http/src/handler_response.rs +190 -0
  55. data/vendor/crates/spikard-http/src/handler_trait.rs +228 -0
  56. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  57. data/vendor/crates/spikard-http/src/lib.rs +529 -0
  58. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  59. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  60. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  61. data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -0
  62. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -0
  63. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  64. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  65. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -0
  66. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -0
  67. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -0
  68. data/vendor/crates/spikard-http/src/parameters.rs +1 -0
  69. data/vendor/crates/spikard-http/src/problem.rs +1 -0
  70. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  71. data/vendor/crates/spikard-http/src/response.rs +399 -0
  72. data/vendor/crates/spikard-http/src/router.rs +1 -0
  73. data/vendor/crates/spikard-http/src/schema_registry.rs +1 -0
  74. data/vendor/crates/spikard-http/src/server/handler.rs +80 -0
  75. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  76. data/vendor/crates/spikard-http/src/server/mod.rs +805 -0
  77. data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -0
  78. data/vendor/crates/spikard-http/src/sse.rs +447 -0
  79. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  80. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  81. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  82. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  83. data/vendor/crates/spikard-http/src/type_hints.rs +1 -0
  84. data/vendor/crates/spikard-http/src/validation.rs +1 -0
  85. data/vendor/crates/spikard-http/src/websocket.rs +324 -0
  86. data/vendor/crates/spikard-rb/Cargo.toml +42 -0
  87. data/vendor/crates/spikard-rb/build.rs +8 -0
  88. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  89. data/vendor/crates/spikard-rb/src/config.rs +294 -0
  90. data/vendor/crates/spikard-rb/src/conversion.rs +392 -0
  91. data/vendor/crates/spikard-rb/src/di.rs +409 -0
  92. data/vendor/crates/spikard-rb/src/handler.rs +534 -0
  93. data/vendor/crates/spikard-rb/src/lib.rs +2020 -0
  94. data/vendor/crates/spikard-rb/src/lifecycle.rs +267 -0
  95. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  96. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  97. data/vendor/crates/spikard-rb/src/test_client.rs +404 -0
  98. data/vendor/crates/spikard-rb/src/test_sse.rs +143 -0
  99. data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -0
  100. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  101. metadata +80 -2
data/lib/spikard/app.rb CHANGED
@@ -1,374 +1,374 @@
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
- @lifecycle_hooks[: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
- @lifecycle_hooks[: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
- @lifecycle_hooks[: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
- @lifecycle_hooks[: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
- @lifecycle_hooks[:on_error] << hook
102
- hook
103
- end
104
-
105
- # Get all registered lifecycle hooks
106
- #
107
- # @return [Hash] Dictionary of hook arrays by type
108
- def lifecycle_hooks
109
- {
110
- on_request: @lifecycle_hooks[:on_request].dup,
111
- pre_validation: @lifecycle_hooks[:pre_validation].dup,
112
- pre_handler: @lifecycle_hooks[:pre_handler].dup,
113
- on_response: @lifecycle_hooks[:on_response].dup,
114
- on_error: @lifecycle_hooks[:on_error].dup
115
- }
116
- end
117
- end
118
-
119
- # Collects route metadata so the Rust engine can execute handlers.
120
- # rubocop:disable Metrics/ClassLength
121
- class App
122
- include LifecycleHooks
123
- include ProvideSupport
124
-
125
- HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
126
- SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors].freeze
127
-
128
- attr_reader :routes
129
-
130
- def initialize
131
- @routes = []
132
- @websocket_handlers = {}
133
- @sse_producers = {}
134
- @dependencies = {}
135
- @lifecycle_hooks = {
136
- on_request: [],
137
- pre_validation: [],
138
- pre_handler: [],
139
- on_response: [],
140
- on_error: []
141
- }
142
- end
143
-
144
- def register_route(method, path, handler_name: nil, **options, &block)
145
- validate_route_arguments!(block, options)
146
- handler_name ||= default_handler_name(method, path)
147
-
148
- # Extract handler dependencies from block parameters
149
- handler_dependencies = extract_handler_dependencies(block)
150
-
151
- metadata = build_metadata(method, path, handler_name, options, handler_dependencies)
152
-
153
- @routes << RouteEntry.new(metadata, block)
154
- block
155
- end
156
-
157
- HTTP_METHODS.each do |verb|
158
- define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
159
- register_route(verb, path, handler_name: handler_name, **options, &block)
160
- end
161
- end
162
-
163
- def route_metadata
164
- # Extract handler dependencies when metadata is requested
165
- # This allows dependencies to be registered after routes
166
- @routes.map do |entry|
167
- metadata = entry.metadata.dup
168
-
169
- # Re-extract dependencies in case they were registered after the route
170
- handler_dependencies = extract_handler_dependencies(entry.handler)
171
- metadata[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
172
-
173
- metadata
174
- end
175
- end
176
-
177
- def handler_map
178
- map = {}
179
- @routes.each do |entry|
180
- name = entry.metadata[:handler_name]
181
- # Pass raw handler - DI resolution happens in Rust layer
182
- map[name] = entry.handler
183
- end
184
- map
185
- end
186
-
187
- def default_handler_name(method, path)
188
- normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_').sub(/^_+|_+$/, '')
189
- normalized_path = 'root' if normalized_path.empty?
190
- "#{method.to_s.downcase}_#{normalized_path}"
191
- end
192
-
193
- # Register a WebSocket endpoint
194
- #
195
- # @param path [String] URL path for the WebSocket endpoint
196
- # @yield Factory block that returns a WebSocketHandler instance
197
- # @return [Proc] The factory block (for chaining)
198
- #
199
- # @example
200
- # app.websocket('/chat') do
201
- # ChatHandler.new
202
- # end
203
- def websocket(path, _handler_name: nil, **_options, &factory)
204
- raise ArgumentError, 'block required for WebSocket handler factory' unless factory
205
-
206
- @websocket_handlers[path] = factory
207
- factory
208
- end
209
-
210
- # Register a Server-Sent Events endpoint
211
- #
212
- # @param path [String] URL path for the SSE endpoint
213
- # @yield Factory block that returns a SseEventProducer instance
214
- # @return [Proc] The factory block (for chaining)
215
- #
216
- # @example
217
- # app.sse('/notifications') do
218
- # NotificationProducer.new
219
- # end
220
- def sse(path, _handler_name: nil, **_options, &factory)
221
- raise ArgumentError, 'block required for SSE producer factory' unless factory
222
-
223
- @sse_producers[path] = factory
224
- factory
225
- end
226
-
227
- # Get all registered WebSocket handlers
228
- #
229
- # @return [Hash] Dictionary mapping paths to handler factory blocks
230
- def websocket_handlers
231
- @websocket_handlers.dup
232
- end
233
-
234
- # Get all registered SSE producers
235
- #
236
- # @return [Hash] Dictionary mapping paths to producer factory blocks
237
- def sse_producers
238
- @sse_producers.dup
239
- end
240
-
241
- # Run the Spikard server with the given configuration
242
- #
243
- # @param config [ServerConfig, Hash, nil] Server configuration
244
- # Can be a ServerConfig object, a Hash with configuration keys, or nil to use defaults.
245
- # If a Hash is provided, it will be converted to a ServerConfig.
246
- # For backward compatibility, also accepts host: and port: keyword arguments.
247
- #
248
- # @example With ServerConfig
249
- # config = Spikard::ServerConfig.new(
250
- # host: '0.0.0.0',
251
- # port: 8080,
252
- # compression: Spikard::CompressionConfig.new(quality: 9)
253
- # )
254
- # app.run(config: config)
255
- #
256
- # @example With Hash
257
- # app.run(config: { host: '0.0.0.0', port: 8080 })
258
- #
259
- # @example Backward compatible (deprecated)
260
- # app.run(host: '0.0.0.0', port: 8000)
261
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
262
- def run(config: nil, host: nil, port: nil)
263
- require 'json'
264
-
265
- # Backward compatibility: if host/port are provided directly, create a config
266
- if config.nil? && (host || port)
267
- config = ServerConfig.new(
268
- host: host || '127.0.0.1',
269
- port: port || 8000
270
- )
271
- elsif config.nil?
272
- config = ServerConfig.new
273
- elsif config.is_a?(Hash)
274
- config = ServerConfig.new(**config)
275
- end
276
-
277
- # Convert route metadata to JSON
278
- routes_json = JSON.generate(route_metadata)
279
-
280
- # Get handler map
281
- handlers = handler_map
282
-
283
- # Get lifecycle hooks
284
- hooks = lifecycle_hooks
285
-
286
- # Get WebSocket handlers and SSE producers
287
- ws_handlers = websocket_handlers
288
- sse_prods = sse_producers
289
-
290
- # Get dependencies for DI
291
- deps = dependencies
292
-
293
- # Call the Rust extension's run_server function
294
- Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods, deps)
295
-
296
- # Keep Ruby process alive while server runs
297
- sleep
298
- rescue LoadError => e
299
- raise 'Failed to load Spikard extension. ' \
300
- "Build it with: task build:ruby\n#{e.message}"
301
- end
302
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
303
-
304
- private
305
-
306
- def normalize_path(path)
307
- # Preserve trailing slash for consistent routing
308
- has_trailing_slash = path.end_with?('/')
309
-
310
- segments = path.split('/').map do |segment|
311
- if segment.start_with?(':') && segment.length > 1
312
- "{#{segment[1..]}}"
313
- else
314
- segment
315
- end
316
- end
317
-
318
- normalized = segments.join('/')
319
- # Restore trailing slash if original path had one
320
- has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
321
- end
322
-
323
- def validate_route_arguments!(block, options)
324
- raise ArgumentError, 'block required for route handler' unless block
325
-
326
- unknown_keys = options.keys - SUPPORTED_OPTIONS
327
- return if unknown_keys.empty?
328
-
329
- raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
330
- end
331
-
332
- def extract_handler_dependencies(block)
333
- # Get the block's parameters
334
- params = block.parameters
335
-
336
- # Extract keyword parameters (dependencies)
337
- # Parameters come in the format [:req/:opt/:keyreq/:key, :param_name]
338
- # :keyreq and :key are keyword parameters (required and optional)
339
- dependencies = []
340
-
341
- params.each do |param_type, param_name|
342
- # Skip the request parameter (usually first positional param)
343
- # Only collect keyword parameters
344
- next unless %i[keyreq key].include?(param_type)
345
-
346
- dep_name = param_name.to_s
347
- # Collect ALL keyword parameters, not just registered ones
348
- # This allows the DI system to validate missing dependencies
349
- dependencies << dep_name
350
- end
351
-
352
- dependencies
353
- end
354
-
355
- def build_metadata(method, path, handler_name, options, handler_dependencies)
356
- base = {
357
- method: method,
358
- path: normalize_path(path),
359
- handler_name: handler_name,
360
- is_async: options.fetch(:is_async, false)
361
- }
362
-
363
- # Add handler_dependencies if present
364
- base[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
365
-
366
- SUPPORTED_OPTIONS.each_with_object(base) do |key, metadata|
367
- next if key == :is_async || !options.key?(key)
368
-
369
- metadata[key] = options[key]
370
- end
371
- end
372
- end
373
- # rubocop:enable Metrics/ClassLength
374
- 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
+ @lifecycle_hooks[: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
+ @lifecycle_hooks[: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
+ @lifecycle_hooks[: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
+ @lifecycle_hooks[: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
+ @lifecycle_hooks[:on_error] << hook
102
+ hook
103
+ end
104
+
105
+ # Get all registered lifecycle hooks
106
+ #
107
+ # @return [Hash] Dictionary of hook arrays by type
108
+ def lifecycle_hooks
109
+ {
110
+ on_request: @lifecycle_hooks[:on_request].dup,
111
+ pre_validation: @lifecycle_hooks[:pre_validation].dup,
112
+ pre_handler: @lifecycle_hooks[:pre_handler].dup,
113
+ on_response: @lifecycle_hooks[:on_response].dup,
114
+ on_error: @lifecycle_hooks[:on_error].dup
115
+ }
116
+ end
117
+ end
118
+
119
+ # Collects route metadata so the Rust engine can execute handlers.
120
+ # rubocop:disable Metrics/ClassLength
121
+ class App
122
+ include LifecycleHooks
123
+ include ProvideSupport
124
+
125
+ HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
126
+ SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors].freeze
127
+
128
+ attr_reader :routes
129
+
130
+ def initialize
131
+ @routes = []
132
+ @websocket_handlers = {}
133
+ @sse_producers = {}
134
+ @dependencies = {}
135
+ @lifecycle_hooks = {
136
+ on_request: [],
137
+ pre_validation: [],
138
+ pre_handler: [],
139
+ on_response: [],
140
+ on_error: []
141
+ }
142
+ end
143
+
144
+ def register_route(method, path, handler_name: nil, **options, &block)
145
+ validate_route_arguments!(block, options)
146
+ handler_name ||= default_handler_name(method, path)
147
+
148
+ # Extract handler dependencies from block parameters
149
+ handler_dependencies = extract_handler_dependencies(block)
150
+
151
+ metadata = build_metadata(method, path, handler_name, options, handler_dependencies)
152
+
153
+ @routes << RouteEntry.new(metadata, block)
154
+ block
155
+ end
156
+
157
+ HTTP_METHODS.each do |verb|
158
+ define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
159
+ register_route(verb, path, handler_name: handler_name, **options, &block)
160
+ end
161
+ end
162
+
163
+ def route_metadata
164
+ # Extract handler dependencies when metadata is requested
165
+ # This allows dependencies to be registered after routes
166
+ @routes.map do |entry|
167
+ metadata = entry.metadata.dup
168
+
169
+ # Re-extract dependencies in case they were registered after the route
170
+ handler_dependencies = extract_handler_dependencies(entry.handler)
171
+ metadata[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
172
+
173
+ metadata
174
+ end
175
+ end
176
+
177
+ def handler_map
178
+ map = {}
179
+ @routes.each do |entry|
180
+ name = entry.metadata[:handler_name]
181
+ # Pass raw handler - DI resolution happens in Rust layer
182
+ map[name] = entry.handler
183
+ end
184
+ map
185
+ end
186
+
187
+ def default_handler_name(method, path)
188
+ normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_').sub(/^_+|_+$/, '')
189
+ normalized_path = 'root' if normalized_path.empty?
190
+ "#{method.to_s.downcase}_#{normalized_path}"
191
+ end
192
+
193
+ # Register a WebSocket endpoint
194
+ #
195
+ # @param path [String] URL path for the WebSocket endpoint
196
+ # @yield Factory block that returns a WebSocketHandler instance
197
+ # @return [Proc] The factory block (for chaining)
198
+ #
199
+ # @example
200
+ # app.websocket('/chat') do
201
+ # ChatHandler.new
202
+ # end
203
+ def websocket(path, _handler_name: nil, **_options, &factory)
204
+ raise ArgumentError, 'block required for WebSocket handler factory' unless factory
205
+
206
+ @websocket_handlers[path] = factory
207
+ factory
208
+ end
209
+
210
+ # Register a Server-Sent Events endpoint
211
+ #
212
+ # @param path [String] URL path for the SSE endpoint
213
+ # @yield Factory block that returns a SseEventProducer instance
214
+ # @return [Proc] The factory block (for chaining)
215
+ #
216
+ # @example
217
+ # app.sse('/notifications') do
218
+ # NotificationProducer.new
219
+ # end
220
+ def sse(path, _handler_name: nil, **_options, &factory)
221
+ raise ArgumentError, 'block required for SSE producer factory' unless factory
222
+
223
+ @sse_producers[path] = factory
224
+ factory
225
+ end
226
+
227
+ # Get all registered WebSocket handlers
228
+ #
229
+ # @return [Hash] Dictionary mapping paths to handler factory blocks
230
+ def websocket_handlers
231
+ @websocket_handlers.dup
232
+ end
233
+
234
+ # Get all registered SSE producers
235
+ #
236
+ # @return [Hash] Dictionary mapping paths to producer factory blocks
237
+ def sse_producers
238
+ @sse_producers.dup
239
+ end
240
+
241
+ # Run the Spikard server with the given configuration
242
+ #
243
+ # @param config [ServerConfig, Hash, nil] Server configuration
244
+ # Can be a ServerConfig object, a Hash with configuration keys, or nil to use defaults.
245
+ # If a Hash is provided, it will be converted to a ServerConfig.
246
+ # For backward compatibility, also accepts host: and port: keyword arguments.
247
+ #
248
+ # @example With ServerConfig
249
+ # config = Spikard::ServerConfig.new(
250
+ # host: '0.0.0.0',
251
+ # port: 8080,
252
+ # compression: Spikard::CompressionConfig.new(quality: 9)
253
+ # )
254
+ # app.run(config: config)
255
+ #
256
+ # @example With Hash
257
+ # app.run(config: { host: '0.0.0.0', port: 8080 })
258
+ #
259
+ # @example Backward compatible (deprecated)
260
+ # app.run(host: '0.0.0.0', port: 8000)
261
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
262
+ def run(config: nil, host: nil, port: nil)
263
+ require 'json'
264
+
265
+ # Backward compatibility: if host/port are provided directly, create a config
266
+ if config.nil? && (host || port)
267
+ config = ServerConfig.new(
268
+ host: host || '127.0.0.1',
269
+ port: port || 8000
270
+ )
271
+ elsif config.nil?
272
+ config = ServerConfig.new
273
+ elsif config.is_a?(Hash)
274
+ config = ServerConfig.new(**config)
275
+ end
276
+
277
+ # Convert route metadata to JSON
278
+ routes_json = JSON.generate(route_metadata)
279
+
280
+ # Get handler map
281
+ handlers = handler_map
282
+
283
+ # Get lifecycle hooks
284
+ hooks = lifecycle_hooks
285
+
286
+ # Get WebSocket handlers and SSE producers
287
+ ws_handlers = websocket_handlers
288
+ sse_prods = sse_producers
289
+
290
+ # Get dependencies for DI
291
+ deps = dependencies
292
+
293
+ # Call the Rust extension's run_server function
294
+ Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods, deps)
295
+
296
+ # Keep Ruby process alive while server runs
297
+ sleep
298
+ rescue LoadError => e
299
+ raise 'Failed to load Spikard extension. ' \
300
+ "Build it with: task build:ruby\n#{e.message}"
301
+ end
302
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
303
+
304
+ private
305
+
306
+ def normalize_path(path)
307
+ # Preserve trailing slash for consistent routing
308
+ has_trailing_slash = path.end_with?('/')
309
+
310
+ segments = path.split('/').map do |segment|
311
+ if segment.start_with?(':') && segment.length > 1
312
+ "{#{segment[1..]}}"
313
+ else
314
+ segment
315
+ end
316
+ end
317
+
318
+ normalized = segments.join('/')
319
+ # Restore trailing slash if original path had one
320
+ has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
321
+ end
322
+
323
+ def validate_route_arguments!(block, options)
324
+ raise ArgumentError, 'block required for route handler' unless block
325
+
326
+ unknown_keys = options.keys - SUPPORTED_OPTIONS
327
+ return if unknown_keys.empty?
328
+
329
+ raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
330
+ end
331
+
332
+ def extract_handler_dependencies(block)
333
+ # Get the block's parameters
334
+ params = block.parameters
335
+
336
+ # Extract keyword parameters (dependencies)
337
+ # Parameters come in the format [:req/:opt/:keyreq/:key, :param_name]
338
+ # :keyreq and :key are keyword parameters (required and optional)
339
+ dependencies = []
340
+
341
+ params.each do |param_type, param_name|
342
+ # Skip the request parameter (usually first positional param)
343
+ # Only collect keyword parameters
344
+ next unless %i[keyreq key].include?(param_type)
345
+
346
+ dep_name = param_name.to_s
347
+ # Collect ALL keyword parameters, not just registered ones
348
+ # This allows the DI system to validate missing dependencies
349
+ dependencies << dep_name
350
+ end
351
+
352
+ dependencies
353
+ end
354
+
355
+ def build_metadata(method, path, handler_name, options, handler_dependencies)
356
+ base = {
357
+ method: method,
358
+ path: normalize_path(path),
359
+ handler_name: handler_name,
360
+ is_async: options.fetch(:is_async, false)
361
+ }
362
+
363
+ # Add handler_dependencies if present
364
+ base[:handler_dependencies] = handler_dependencies unless handler_dependencies.empty?
365
+
366
+ SUPPORTED_OPTIONS.each_with_object(base) do |key, metadata|
367
+ next if key == :is_async || !options.key?(key)
368
+
369
+ metadata[key] = options[key]
370
+ end
371
+ end
372
+ end
373
+ # rubocop:enable Metrics/ClassLength
374
+ end