spikard 0.3.4 → 0.3.6

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +659 -659
  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 +386 -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 +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 +366 -360
  23. data/vendor/crates/spikard-core/Cargo.toml +40 -40
  24. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
  25. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
  26. data/vendor/crates/spikard-core/src/debug.rs +63 -63
  27. data/vendor/crates/spikard-core/src/di/container.rs +726 -726
  28. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
  29. data/vendor/crates/spikard-core/src/di/error.rs +118 -118
  30. data/vendor/crates/spikard-core/src/di/factory.rs +538 -538
  31. data/vendor/crates/spikard-core/src/di/graph.rs +545 -545
  32. data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -411
  34. data/vendor/crates/spikard-core/src/di/value.rs +283 -283
  35. data/vendor/crates/spikard-core/src/errors.rs +39 -39
  36. data/vendor/crates/spikard-core/src/http.rs +153 -153
  37. data/vendor/crates/spikard-core/src/lib.rs +29 -29
  38. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -422
  39. data/vendor/crates/spikard-core/src/parameters.rs +722 -722
  40. data/vendor/crates/spikard-core/src/problem.rs +310 -310
  41. data/vendor/crates/spikard-core/src/request_data.rs +189 -189
  42. data/vendor/crates/spikard-core/src/router.rs +249 -249
  43. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
  44. data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
  45. data/vendor/crates/spikard-core/src/validation.rs +699 -699
  46. data/vendor/crates/spikard-http/Cargo.toml +68 -58
  47. data/vendor/crates/spikard-http/src/auth.rs +247 -247
  48. data/vendor/crates/spikard-http/src/background.rs +249 -249
  49. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
  50. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
  51. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
  52. data/vendor/crates/spikard-http/src/cors.rs +490 -490
  53. data/vendor/crates/spikard-http/src/debug.rs +63 -63
  54. data/vendor/crates/spikard-http/src/di_handler.rs +423 -423
  55. data/vendor/crates/spikard-http/src/handler_response.rs +190 -190
  56. data/vendor/crates/spikard-http/src/handler_trait.rs +228 -228
  57. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -284
  58. data/vendor/crates/spikard-http/src/lib.rs +529 -529
  59. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -149
  60. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -428
  61. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -285
  62. data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -86
  63. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -147
  64. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -287
  65. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
  66. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -190
  67. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -308
  68. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -195
  69. data/vendor/crates/spikard-http/src/parameters.rs +1 -1
  70. data/vendor/crates/spikard-http/src/problem.rs +1 -1
  71. data/vendor/crates/spikard-http/src/query_parser.rs +369 -369
  72. data/vendor/crates/spikard-http/src/response.rs +399 -399
  73. data/vendor/crates/spikard-http/src/router.rs +1 -1
  74. data/vendor/crates/spikard-http/src/schema_registry.rs +1 -1
  75. data/vendor/crates/spikard-http/src/server/handler.rs +87 -87
  76. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -98
  77. data/vendor/crates/spikard-http/src/server/mod.rs +805 -805
  78. data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -119
  79. data/vendor/crates/spikard-http/src/sse.rs +447 -447
  80. data/vendor/crates/spikard-http/src/testing/form.rs +14 -14
  81. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -60
  82. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -285
  83. data/vendor/crates/spikard-http/src/testing.rs +377 -377
  84. data/vendor/crates/spikard-http/src/type_hints.rs +1 -1
  85. data/vendor/crates/spikard-http/src/validation.rs +1 -1
  86. data/vendor/crates/spikard-http/src/websocket.rs +324 -324
  87. data/vendor/crates/spikard-rb/Cargo.toml +42 -42
  88. data/vendor/crates/spikard-rb/build.rs +8 -8
  89. data/vendor/crates/spikard-rb/src/background.rs +63 -63
  90. data/vendor/crates/spikard-rb/src/config.rs +294 -294
  91. data/vendor/crates/spikard-rb/src/conversion.rs +453 -453
  92. data/vendor/crates/spikard-rb/src/di.rs +409 -409
  93. data/vendor/crates/spikard-rb/src/handler.rs +625 -625
  94. data/vendor/crates/spikard-rb/src/lib.rs +2771 -2771
  95. data/vendor/crates/spikard-rb/src/lifecycle.rs +274 -274
  96. data/vendor/crates/spikard-rb/src/server.rs +283 -283
  97. data/vendor/crates/spikard-rb/src/sse.rs +231 -231
  98. data/vendor/crates/spikard-rb/src/test_client.rs +404 -404
  99. data/vendor/crates/spikard-rb/src/test_sse.rs +143 -143
  100. data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -221
  101. data/vendor/crates/spikard-rb/src/websocket.rs +233 -233
  102. metadata +1 -79
  103. data/vendor/spikard-core/Cargo.toml +0 -40
  104. data/vendor/spikard-core/src/bindings/mod.rs +0 -3
  105. data/vendor/spikard-core/src/bindings/response.rs +0 -133
  106. data/vendor/spikard-core/src/debug.rs +0 -63
  107. data/vendor/spikard-core/src/di/container.rs +0 -726
  108. data/vendor/spikard-core/src/di/dependency.rs +0 -273
  109. data/vendor/spikard-core/src/di/error.rs +0 -118
  110. data/vendor/spikard-core/src/di/factory.rs +0 -538
  111. data/vendor/spikard-core/src/di/graph.rs +0 -545
  112. data/vendor/spikard-core/src/di/mod.rs +0 -192
  113. data/vendor/spikard-core/src/di/resolved.rs +0 -411
  114. data/vendor/spikard-core/src/di/value.rs +0 -283
  115. data/vendor/spikard-core/src/http.rs +0 -153
  116. data/vendor/spikard-core/src/lib.rs +0 -28
  117. data/vendor/spikard-core/src/lifecycle.rs +0 -422
  118. data/vendor/spikard-core/src/parameters.rs +0 -719
  119. data/vendor/spikard-core/src/problem.rs +0 -310
  120. data/vendor/spikard-core/src/request_data.rs +0 -189
  121. data/vendor/spikard-core/src/router.rs +0 -249
  122. data/vendor/spikard-core/src/schema_registry.rs +0 -183
  123. data/vendor/spikard-core/src/type_hints.rs +0 -304
  124. data/vendor/spikard-core/src/validation.rs +0 -699
  125. data/vendor/spikard-http/Cargo.toml +0 -58
  126. data/vendor/spikard-http/src/auth.rs +0 -247
  127. data/vendor/spikard-http/src/background.rs +0 -249
  128. data/vendor/spikard-http/src/bindings/mod.rs +0 -3
  129. data/vendor/spikard-http/src/bindings/response.rs +0 -1
  130. data/vendor/spikard-http/src/body_metadata.rs +0 -8
  131. data/vendor/spikard-http/src/cors.rs +0 -490
  132. data/vendor/spikard-http/src/debug.rs +0 -63
  133. data/vendor/spikard-http/src/di_handler.rs +0 -423
  134. data/vendor/spikard-http/src/handler_response.rs +0 -190
  135. data/vendor/spikard-http/src/handler_trait.rs +0 -228
  136. data/vendor/spikard-http/src/handler_trait_tests.rs +0 -284
  137. data/vendor/spikard-http/src/lib.rs +0 -529
  138. data/vendor/spikard-http/src/lifecycle/adapter.rs +0 -149
  139. data/vendor/spikard-http/src/lifecycle.rs +0 -428
  140. data/vendor/spikard-http/src/middleware/mod.rs +0 -285
  141. data/vendor/spikard-http/src/middleware/multipart.rs +0 -86
  142. data/vendor/spikard-http/src/middleware/urlencoded.rs +0 -147
  143. data/vendor/spikard-http/src/middleware/validation.rs +0 -287
  144. data/vendor/spikard-http/src/openapi/mod.rs +0 -309
  145. data/vendor/spikard-http/src/openapi/parameter_extraction.rs +0 -190
  146. data/vendor/spikard-http/src/openapi/schema_conversion.rs +0 -308
  147. data/vendor/spikard-http/src/openapi/spec_generation.rs +0 -195
  148. data/vendor/spikard-http/src/parameters.rs +0 -1
  149. data/vendor/spikard-http/src/problem.rs +0 -1
  150. data/vendor/spikard-http/src/query_parser.rs +0 -369
  151. data/vendor/spikard-http/src/response.rs +0 -399
  152. data/vendor/spikard-http/src/router.rs +0 -1
  153. data/vendor/spikard-http/src/schema_registry.rs +0 -1
  154. data/vendor/spikard-http/src/server/handler.rs +0 -80
  155. data/vendor/spikard-http/src/server/lifecycle_execution.rs +0 -98
  156. data/vendor/spikard-http/src/server/mod.rs +0 -805
  157. data/vendor/spikard-http/src/server/request_extraction.rs +0 -119
  158. data/vendor/spikard-http/src/sse.rs +0 -447
  159. data/vendor/spikard-http/src/testing/form.rs +0 -14
  160. data/vendor/spikard-http/src/testing/multipart.rs +0 -60
  161. data/vendor/spikard-http/src/testing/test_client.rs +0 -285
  162. data/vendor/spikard-http/src/testing.rs +0 -377
  163. data/vendor/spikard-http/src/type_hints.rs +0 -1
  164. data/vendor/spikard-http/src/validation.rs +0 -1
  165. data/vendor/spikard-http/src/websocket.rs +0 -324
  166. data/vendor/spikard-rb/Cargo.toml +0 -42
  167. data/vendor/spikard-rb/build.rs +0 -8
  168. data/vendor/spikard-rb/src/background.rs +0 -63
  169. data/vendor/spikard-rb/src/config.rs +0 -294
  170. data/vendor/spikard-rb/src/conversion.rs +0 -392
  171. data/vendor/spikard-rb/src/di.rs +0 -409
  172. data/vendor/spikard-rb/src/handler.rs +0 -534
  173. data/vendor/spikard-rb/src/lib.rs +0 -2020
  174. data/vendor/spikard-rb/src/lifecycle.rs +0 -267
  175. data/vendor/spikard-rb/src/server.rs +0 -283
  176. data/vendor/spikard-rb/src/sse.rs +0 -231
  177. data/vendor/spikard-rb/src/test_client.rs +0 -404
  178. data/vendor/spikard-rb/src/test_sse.rs +0 -143
  179. data/vendor/spikard-rb/src/test_websocket.rs +0 -221
  180. data/vendor/spikard-rb/src/websocket.rs +0 -233
data/lib/spikard/app.rb CHANGED
@@ -1,386 +1,386 @@
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].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