spikard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
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
+
124
+ HTTP_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
125
+ SUPPORTED_OPTIONS = %i[request_schema response_schema parameter_schema file_params is_async cors].freeze
126
+
127
+ attr_reader :routes
128
+
129
+ def initialize
130
+ @routes = []
131
+ @websocket_handlers = {}
132
+ @sse_producers = {}
133
+ @lifecycle_hooks = {
134
+ on_request: [],
135
+ pre_validation: [],
136
+ pre_handler: [],
137
+ on_response: [],
138
+ on_error: []
139
+ }
140
+ end
141
+
142
+ def register_route(method, path, handler_name: nil, **options, &block)
143
+ validate_route_arguments!(block, options)
144
+ handler_name ||= default_handler_name(method, path)
145
+ metadata = build_metadata(method, path, handler_name, options)
146
+
147
+ @routes << RouteEntry.new(metadata, block)
148
+ block
149
+ end
150
+
151
+ HTTP_METHODS.each do |verb|
152
+ define_method(verb.downcase) do |path, handler_name: nil, **options, &block|
153
+ register_route(verb, path, handler_name: handler_name, **options, &block)
154
+ end
155
+ end
156
+
157
+ def route_metadata
158
+ @routes.map(&:metadata)
159
+ end
160
+
161
+ def handler_map
162
+ map = {}
163
+ @routes.each do |entry|
164
+ name = entry.metadata[:handler_name]
165
+ map[name] = entry.handler
166
+ end
167
+ map
168
+ end
169
+
170
+ def default_handler_name(method, path)
171
+ normalized_path = path.gsub(/[^a-zA-Z0-9]+/, '_').gsub(/__+/, '_').sub(/^_+|_+$/, '')
172
+ normalized_path = 'root' if normalized_path.empty?
173
+ "#{method.to_s.downcase}_#{normalized_path}"
174
+ end
175
+
176
+ # Register a WebSocket endpoint
177
+ #
178
+ # @param path [String] URL path for the WebSocket endpoint
179
+ # @yield Factory block that returns a WebSocketHandler instance
180
+ # @return [Proc] The factory block (for chaining)
181
+ #
182
+ # @example
183
+ # app.websocket('/chat') do
184
+ # ChatHandler.new
185
+ # end
186
+ def websocket(path, _handler_name: nil, **_options, &factory)
187
+ raise ArgumentError, 'block required for WebSocket handler factory' unless factory
188
+
189
+ @websocket_handlers[path] = factory
190
+ factory
191
+ end
192
+
193
+ # Register a Server-Sent Events endpoint
194
+ #
195
+ # @param path [String] URL path for the SSE endpoint
196
+ # @yield Factory block that returns a SseEventProducer instance
197
+ # @return [Proc] The factory block (for chaining)
198
+ #
199
+ # @example
200
+ # app.sse('/notifications') do
201
+ # NotificationProducer.new
202
+ # end
203
+ def sse(path, _handler_name: nil, **_options, &factory)
204
+ raise ArgumentError, 'block required for SSE producer factory' unless factory
205
+
206
+ @sse_producers[path] = factory
207
+ factory
208
+ end
209
+
210
+ # Get all registered WebSocket handlers
211
+ #
212
+ # @return [Hash] Dictionary mapping paths to handler factory blocks
213
+ def websocket_handlers
214
+ @websocket_handlers.dup
215
+ end
216
+
217
+ # Get all registered SSE producers
218
+ #
219
+ # @return [Hash] Dictionary mapping paths to producer factory blocks
220
+ def sse_producers
221
+ @sse_producers.dup
222
+ end
223
+
224
+ # Run the Spikard server with the given configuration
225
+ #
226
+ # @param config [ServerConfig, Hash, nil] Server configuration
227
+ # Can be a ServerConfig object, a Hash with configuration keys, or nil to use defaults.
228
+ # If a Hash is provided, it will be converted to a ServerConfig.
229
+ # For backward compatibility, also accepts host: and port: keyword arguments.
230
+ #
231
+ # @example With ServerConfig
232
+ # config = Spikard::ServerConfig.new(
233
+ # host: '0.0.0.0',
234
+ # port: 8080,
235
+ # compression: Spikard::CompressionConfig.new(quality: 9)
236
+ # )
237
+ # app.run(config: config)
238
+ #
239
+ # @example With Hash
240
+ # app.run(config: { host: '0.0.0.0', port: 8080 })
241
+ #
242
+ # @example Backward compatible (deprecated)
243
+ # app.run(host: '0.0.0.0', port: 8000)
244
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
245
+ def run(config: nil, host: nil, port: nil)
246
+ require 'json'
247
+
248
+ # Backward compatibility: if host/port are provided directly, create a config
249
+ if config.nil? && (host || port)
250
+ config = ServerConfig.new(
251
+ host: host || '127.0.0.1',
252
+ port: port || 8000
253
+ )
254
+ elsif config.nil?
255
+ config = ServerConfig.new
256
+ elsif config.is_a?(Hash)
257
+ config = ServerConfig.new(**config)
258
+ end
259
+
260
+ # Convert route metadata to JSON
261
+ routes_json = JSON.generate(route_metadata)
262
+
263
+ # Get handler map
264
+ handlers = handler_map
265
+
266
+ # Get lifecycle hooks
267
+ hooks = lifecycle_hooks
268
+
269
+ # Get WebSocket handlers and SSE producers
270
+ ws_handlers = websocket_handlers
271
+ sse_prods = sse_producers
272
+
273
+ # Call the Rust extension's run_server function
274
+ Spikard::Native.run_server(routes_json, handlers, config, hooks, ws_handlers, sse_prods)
275
+
276
+ # Keep Ruby process alive while server runs
277
+ sleep
278
+ rescue LoadError => e
279
+ raise 'Failed to load Spikard extension. ' \
280
+ "Build it with: task build:ruby\n#{e.message}"
281
+ end
282
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
283
+
284
+ private
285
+
286
+ def normalize_path(path)
287
+ # Preserve trailing slash for consistent routing
288
+ has_trailing_slash = path.end_with?('/')
289
+
290
+ segments = path.split('/').map do |segment|
291
+ if segment.start_with?(':') && segment.length > 1
292
+ "{#{segment[1..]}}"
293
+ else
294
+ segment
295
+ end
296
+ end
297
+
298
+ normalized = segments.join('/')
299
+ # Restore trailing slash if original path had one
300
+ has_trailing_slash && !normalized.end_with?('/') ? "#{normalized}/" : normalized
301
+ end
302
+
303
+ def validate_route_arguments!(block, options)
304
+ raise ArgumentError, 'block required for route handler' unless block
305
+
306
+ unknown_keys = options.keys - SUPPORTED_OPTIONS
307
+ return if unknown_keys.empty?
308
+
309
+ raise ArgumentError, "unknown route options: #{unknown_keys.join(', ')}"
310
+ end
311
+
312
+ def build_metadata(method, path, handler_name, options)
313
+ base = {
314
+ method: method,
315
+ path: normalize_path(path),
316
+ handler_name: handler_name,
317
+ is_async: options.fetch(:is_async, false)
318
+ }
319
+
320
+ SUPPORTED_OPTIONS.each_with_object(base) do |key, metadata|
321
+ next if key == :is_async || !options.key?(key)
322
+
323
+ metadata[key] = options[key]
324
+ end
325
+ end
326
+ end
327
+ # rubocop:enable Metrics/ClassLength
328
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ # Background job helpers.
5
+ module Background
6
+ module_function
7
+
8
+ @queue = Queue.new
9
+ @worker = Thread.new do
10
+ loop do
11
+ job = @queue.pop
12
+ begin
13
+ job.call
14
+ rescue StandardError => e
15
+ warn("[spikard.background] job failed: #{e.message}")
16
+ end
17
+ end
18
+ end
19
+
20
+ # Schedule a block to run after the response has been returned.
21
+ def run(&block)
22
+ raise ArgumentError, 'background.run requires a block' unless block
23
+
24
+ @queue << block
25
+ end
26
+ end
27
+ end