whoosh 1.0.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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +413 -0
  4. data/exe/whoosh +6 -0
  5. data/lib/whoosh/app.rb +655 -0
  6. data/lib/whoosh/auth/access_control.rb +26 -0
  7. data/lib/whoosh/auth/api_key.rb +30 -0
  8. data/lib/whoosh/auth/jwt.rb +88 -0
  9. data/lib/whoosh/auth/oauth2.rb +33 -0
  10. data/lib/whoosh/auth/rate_limiter.rb +86 -0
  11. data/lib/whoosh/auth/token_tracker.rb +40 -0
  12. data/lib/whoosh/cache/memory_store.rb +57 -0
  13. data/lib/whoosh/cache/redis_store.rb +72 -0
  14. data/lib/whoosh/cache.rb +26 -0
  15. data/lib/whoosh/cli/generators.rb +133 -0
  16. data/lib/whoosh/cli/main.rb +277 -0
  17. data/lib/whoosh/cli/project_generator.rb +172 -0
  18. data/lib/whoosh/config.rb +160 -0
  19. data/lib/whoosh/database.rb +47 -0
  20. data/lib/whoosh/dependency_injection.rb +103 -0
  21. data/lib/whoosh/endpoint.rb +79 -0
  22. data/lib/whoosh/env_loader.rb +46 -0
  23. data/lib/whoosh/errors.rb +68 -0
  24. data/lib/whoosh/http/response.rb +26 -0
  25. data/lib/whoosh/http.rb +73 -0
  26. data/lib/whoosh/instrumentation.rb +22 -0
  27. data/lib/whoosh/job.rb +24 -0
  28. data/lib/whoosh/jobs/memory_backend.rb +45 -0
  29. data/lib/whoosh/jobs/worker.rb +73 -0
  30. data/lib/whoosh/jobs.rb +50 -0
  31. data/lib/whoosh/logger.rb +62 -0
  32. data/lib/whoosh/mcp/client.rb +71 -0
  33. data/lib/whoosh/mcp/client_manager.rb +73 -0
  34. data/lib/whoosh/mcp/protocol.rb +39 -0
  35. data/lib/whoosh/mcp/server.rb +66 -0
  36. data/lib/whoosh/mcp/transport/sse.rb +26 -0
  37. data/lib/whoosh/mcp/transport/stdio.rb +33 -0
  38. data/lib/whoosh/metrics.rb +84 -0
  39. data/lib/whoosh/middleware/cors.rb +61 -0
  40. data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
  41. data/lib/whoosh/middleware/request_limit.rb +28 -0
  42. data/lib/whoosh/middleware/request_logger.rb +39 -0
  43. data/lib/whoosh/middleware/security_headers.rb +28 -0
  44. data/lib/whoosh/middleware/stack.rb +25 -0
  45. data/lib/whoosh/openapi/generator.rb +50 -0
  46. data/lib/whoosh/openapi/schema_converter.rb +48 -0
  47. data/lib/whoosh/openapi/ui.rb +62 -0
  48. data/lib/whoosh/paginate.rb +64 -0
  49. data/lib/whoosh/performance.rb +20 -0
  50. data/lib/whoosh/plugins/base.rb +42 -0
  51. data/lib/whoosh/plugins/registry.rb +139 -0
  52. data/lib/whoosh/request.rb +93 -0
  53. data/lib/whoosh/response.rb +39 -0
  54. data/lib/whoosh/router.rb +112 -0
  55. data/lib/whoosh/schema.rb +194 -0
  56. data/lib/whoosh/serialization/json.rb +73 -0
  57. data/lib/whoosh/serialization/msgpack.rb +51 -0
  58. data/lib/whoosh/serialization/negotiator.rb +37 -0
  59. data/lib/whoosh/serialization/protobuf.rb +43 -0
  60. data/lib/whoosh/shutdown.rb +30 -0
  61. data/lib/whoosh/storage/local.rb +24 -0
  62. data/lib/whoosh/storage/s3.rb +31 -0
  63. data/lib/whoosh/storage.rb +20 -0
  64. data/lib/whoosh/streaming/llm_stream.rb +51 -0
  65. data/lib/whoosh/streaming/sse.rb +61 -0
  66. data/lib/whoosh/streaming/stream_body.rb +59 -0
  67. data/lib/whoosh/streaming/websocket.rb +51 -0
  68. data/lib/whoosh/test.rb +70 -0
  69. data/lib/whoosh/types.rb +11 -0
  70. data/lib/whoosh/uploaded_file.rb +47 -0
  71. data/lib/whoosh/version.rb +5 -0
  72. data/lib/whoosh.rb +86 -0
  73. metadata +265 -0
data/lib/whoosh/app.rb ADDED
@@ -0,0 +1,655 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+ require "stringio"
6
+
7
+ module Whoosh
8
+ class App
9
+ attr_reader :config, :logger, :plugin_registry, :authenticator, :rate_limiter_instance, :token_tracker, :acl, :mcp_server, :mcp_manager, :instrumentation, :shutdown, :metrics
10
+
11
+ def initialize(root: Dir.pwd)
12
+ EnvLoader.load(root)
13
+ @config = Config.load(root: root)
14
+ @router = Router.new
15
+ @middleware_stack = Middleware::Stack.new
16
+ @di = DependencyInjection.new
17
+ @error_handlers = {}
18
+ @default_error_handler = nil
19
+ @logger = Whoosh::Logger.new(
20
+ format: @config.log_format.to_sym,
21
+ level: @config.log_level.to_sym
22
+ )
23
+ @group_prefix = ""
24
+ @group_middleware = []
25
+ @group_metadata = {}
26
+ @plugin_registry = Plugins::Registry.new
27
+ load_plugin_config
28
+ auto_register_cache
29
+ auto_register_database
30
+ auto_register_storage
31
+ auto_register_http
32
+ auto_configure_jobs
33
+ @metrics = Metrics.new
34
+ auto_register_metrics
35
+ @authenticator = nil
36
+ @rate_limiter_instance = nil
37
+ @token_tracker = Auth::TokenTracker.new
38
+ @acl = Auth::AccessControl.new
39
+ @instrumentation = Instrumentation.new
40
+ @mcp_server = MCP::Server.new
41
+ @mcp_manager = MCP::ClientManager.new
42
+ @openapi_config = { title: "Whoosh API", version: Whoosh::VERSION }
43
+ @docs_config = {}
44
+ @shutdown = Shutdown.new(logger: @logger)
45
+
46
+ setup_default_middleware
47
+ end
48
+
49
+ # --- HTTP verb methods ---
50
+
51
+ def get(path, **opts, &block)
52
+ add_route("GET", path, **opts, &block)
53
+ end
54
+
55
+ def post(path, **opts, &block)
56
+ add_route("POST", path, **opts, &block)
57
+ end
58
+
59
+ def put(path, **opts, &block)
60
+ add_route("PUT", path, **opts, &block)
61
+ end
62
+
63
+ def patch(path, **opts, &block)
64
+ add_route("PATCH", path, **opts, &block)
65
+ end
66
+
67
+ def delete(path, **opts, &block)
68
+ add_route("DELETE", path, **opts, &block)
69
+ end
70
+
71
+ def options(path, **opts, &block)
72
+ add_route("OPTIONS", path, **opts, &block)
73
+ end
74
+
75
+ # --- Route groups ---
76
+
77
+ def group(prefix, middleware: [], **metadata, &block)
78
+ previous_prefix = @group_prefix
79
+ previous_middleware = @group_middleware
80
+ previous_metadata = @group_metadata
81
+
82
+ @group_prefix = "#{previous_prefix}#{prefix}"
83
+ @group_middleware = previous_middleware + middleware
84
+ @group_metadata = previous_metadata.merge(metadata)
85
+
86
+ instance_eval(&block)
87
+ ensure
88
+ @group_prefix = previous_prefix
89
+ @group_middleware = previous_middleware
90
+ @group_metadata = previous_metadata
91
+ end
92
+
93
+ # --- Dependency injection ---
94
+
95
+ def provide(name, scope: :singleton, &block)
96
+ @di.provide(name, scope: scope, &block)
97
+ end
98
+
99
+ # --- Error handling ---
100
+
101
+ def on_error(exception_class = nil, &block)
102
+ if exception_class
103
+ @error_handlers[exception_class] = block
104
+ else
105
+ @default_error_handler = block
106
+ end
107
+ end
108
+
109
+ # --- Instrumentation ---
110
+
111
+ def on_event(event, &block)
112
+ @instrumentation.on(event, &block)
113
+ end
114
+
115
+ # --- Route listing ---
116
+
117
+ def routes
118
+ @router.routes
119
+ end
120
+
121
+ # --- Plugin DSL ---
122
+
123
+ def plugin(name, enabled: true, **config)
124
+ if enabled == false
125
+ @plugin_registry.disable(name)
126
+ else
127
+ @plugin_registry.configure(name, config) unless config.empty?
128
+ end
129
+ end
130
+
131
+ def setup_plugin_accessors
132
+ @plugin_registry.define_accessors(self)
133
+ end
134
+
135
+ # --- Auth DSL ---
136
+
137
+ def auth(&block)
138
+ builder = AuthBuilder.new
139
+ builder.instance_eval(&block)
140
+ @authenticator = builder.build
141
+ end
142
+
143
+ def rate_limit(&block)
144
+ builder = RateLimitBuilder.new
145
+ builder.instance_eval(&block)
146
+ @rate_limiter_instance = builder.build
147
+ end
148
+
149
+ def token_tracking(&block)
150
+ builder = TokenTrackingBuilder.new(@token_tracker)
151
+ builder.instance_eval(&block)
152
+ end
153
+
154
+ def access_control(&block)
155
+ @acl.instance_eval(&block)
156
+ end
157
+
158
+ # --- MCP DSL ---
159
+
160
+ def mcp_client(name, command:, **options)
161
+ @mcp_manager.register(name, command: command, **options)
162
+ end
163
+
164
+ # --- Docs DSL ---
165
+
166
+ def docs(enabled: true, redoc: false)
167
+ @docs_config = { enabled: enabled, redoc: redoc }
168
+ end
169
+
170
+ # --- OpenAPI DSL ---
171
+
172
+ def openapi(&block)
173
+ builder = OpenAPIConfigBuilder.new
174
+ builder.instance_eval(&block)
175
+ @openapi_config.merge!(builder.to_h)
176
+ end
177
+
178
+ # --- Health check ---
179
+
180
+ def health_check(path: "/healthz", &block)
181
+ probes = {}
182
+ if block
183
+ builder = HealthCheckBuilder.new
184
+ builder.instance_eval(&block)
185
+ probes = builder.probes
186
+ end
187
+
188
+ get path do
189
+ checks = {}
190
+ all_ok = true
191
+ probes.each do |name, probe_block|
192
+ begin
193
+ probe_block.call
194
+ checks[name.to_s] = "ok"
195
+ rescue => e
196
+ checks[name.to_s] = "fail: #{e.message}"
197
+ all_ok = false
198
+ end
199
+ end
200
+
201
+ result = { status: all_ok ? "ok" : "degraded", version: Whoosh::VERSION }
202
+ result[:checks] = checks unless checks.empty?
203
+
204
+ if all_ok
205
+ result
206
+ else
207
+ [503, { "content-type" => "application/json" }, [Serialization::Json.encode(result)]]
208
+ end
209
+ end
210
+ end
211
+
212
+ # --- Streaming helpers ---
213
+
214
+ def stream(type, &block)
215
+ case type
216
+ when :sse
217
+ body = Streaming::StreamBody.new do |out|
218
+ sse = Streaming::SSE.new(out)
219
+ block.call(sse)
220
+ end
221
+ [200, Streaming::SSE.headers, body]
222
+ else
223
+ raise ArgumentError, "Unknown stream type: #{type}"
224
+ end
225
+ end
226
+
227
+ def stream_llm(&block)
228
+ body = Streaming::StreamBody.new do |out|
229
+ llm_stream = Streaming::LlmStream.new(out)
230
+ block.call(llm_stream)
231
+ llm_stream.finish
232
+ end
233
+ [200, Streaming::LlmStream.headers, body]
234
+ end
235
+
236
+ def paginate(collection, page:, per_page: 20)
237
+ Paginate.offset(collection, page: page, per_page: per_page)
238
+ end
239
+
240
+ def paginate_cursor(collection, cursor: nil, limit: 20, column: :id)
241
+ Paginate.cursor(collection, cursor: cursor, limit: limit, column: column)
242
+ end
243
+
244
+ # --- Endpoint loading ---
245
+
246
+ def load_endpoints(dir)
247
+ before = ObjectSpace.each_object(Class).select { |k| k < Endpoint }.to_set
248
+
249
+ Dir.glob(File.join(dir, "**", "*.rb")).sort.each do |file|
250
+ require file
251
+ end
252
+
253
+ after = ObjectSpace.each_object(Class).select { |k| k < Endpoint }.to_set
254
+ (after - before).each { |klass| register_endpoint(klass) }
255
+ end
256
+
257
+ def register_endpoint(endpoint_class)
258
+ endpoint_class.declared_routes.each do |route|
259
+ handler = {
260
+ block: nil,
261
+ endpoint_class: endpoint_class,
262
+ request_schema: route[:request_schema],
263
+ response_schema: route[:response_schema],
264
+ middleware: []
265
+ }
266
+ @router.add(route[:method], route[:path], handler, **route[:metadata])
267
+ end
268
+ end
269
+
270
+ # --- Rack interface ---
271
+
272
+ def to_rack
273
+ @rack_app ||= begin
274
+ @di.validate!
275
+ register_mcp_tools
276
+ register_doc_routes if @config.docs_enabled?
277
+ register_metrics_route
278
+ @router.freeze!
279
+ inner = method(:handle_request)
280
+ app = @middleware_stack.build(inner)
281
+ start_job_workers
282
+ @shutdown.register { @di.close_all }
283
+ @shutdown.register { @mcp_manager.shutdown_all }
284
+ @shutdown.install_signal_handlers!
285
+ app
286
+ end
287
+ end
288
+
289
+ private
290
+
291
+ def auto_register_cache
292
+ @di.provide(:cache) { Cache.build(@config.data) }
293
+ end
294
+
295
+ def auto_register_storage
296
+ @di.provide(:storage) { Storage.build(@config.data) }
297
+ end
298
+
299
+ def auto_register_http
300
+ @di.provide(:http) { HTTP }
301
+ end
302
+
303
+ def auto_configure_jobs
304
+ backend = Jobs::MemoryBackend.new
305
+ Jobs.configure(backend: backend, di: @di)
306
+ end
307
+
308
+ def auto_register_metrics
309
+ @di.provide(:metrics) { @metrics }
310
+ end
311
+
312
+ def start_job_workers
313
+ jobs_config = @config.data["jobs"] || {}
314
+ worker_count = jobs_config["workers"] || 2
315
+ max_retries = jobs_config["retry"] || 3
316
+ retry_delay = jobs_config["retry_delay"] || 5
317
+
318
+ @job_workers = worker_count.times.map do
319
+ worker = Jobs::Worker.new(
320
+ backend: Jobs.backend, di: @di,
321
+ max_retries: max_retries, retry_delay: retry_delay,
322
+ instrumentation: @instrumentation
323
+ )
324
+ thread = Thread.new { worker.run_loop }
325
+ thread.abort_on_exception = false
326
+ { worker: worker, thread: thread }
327
+ end
328
+
329
+ @shutdown.register do
330
+ @job_workers&.each { |w| w[:worker].stop }
331
+ Jobs.backend&.shutdown
332
+ end
333
+ end
334
+
335
+ def auto_register_database
336
+ db_config = Database.config_from(@config.data)
337
+ return unless db_config
338
+
339
+ unless Database.available?
340
+ @logger.warn("database_unavailable", message: "database config found but sequel gem not installed")
341
+ return
342
+ end
343
+
344
+ @di.provide(:db) { Database.connect_from_config(@config.data, logger: @logger) }
345
+ end
346
+
347
+ def load_plugin_config
348
+ root = @config.instance_variable_get(:@root)
349
+ path = File.join(root, "config", "plugins.yml")
350
+ return unless File.exist?(path)
351
+
352
+ require "yaml"
353
+ data = YAML.safe_load(File.read(path), permitted_classes: [Symbol]) || {}
354
+ data.each do |accessor_name, config|
355
+ name = accessor_name.to_sym
356
+ if config.is_a?(Hash) && config["enabled"] == false
357
+ @plugin_registry.disable(name)
358
+ elsif config.is_a?(Hash)
359
+ @plugin_registry.configure(name, config.reject { |k, _| k == "enabled" })
360
+ end
361
+ end
362
+ end
363
+
364
+ def setup_default_middleware
365
+ @middleware_stack.use(Middleware::RequestLimit)
366
+ @middleware_stack.use(Middleware::SecurityHeaders)
367
+ @middleware_stack.use(Middleware::Cors)
368
+ @middleware_stack.use(Middleware::RequestLogger, logger: @logger, metrics: @metrics)
369
+ end
370
+
371
+ def register_mcp_tools
372
+ @router.routes.each do |route|
373
+ next unless route[:metadata] && route[:metadata][:mcp]
374
+
375
+ tool_name = "#{route[:method]} #{route[:path]}"
376
+ match = @router.match(route[:method], route[:path])
377
+ next unless match
378
+
379
+ handler_data = match[:handler]
380
+ app_ref = self
381
+
382
+ input_schema = if handler_data[:request_schema]
383
+ OpenAPI::SchemaConverter.convert(handler_data[:request_schema])
384
+ else
385
+ {}
386
+ end
387
+
388
+ @mcp_server.register_tool(
389
+ name: tool_name,
390
+ description: tool_name,
391
+ input_schema: input_schema,
392
+ handler: ->(params) {
393
+ env = Rack::MockRequest.env_for(route[:path], method: route[:method],
394
+ input: JSON.generate(params), "CONTENT_TYPE" => "application/json")
395
+ request = Request.new(env)
396
+
397
+ if handler_data[:endpoint_class]
398
+ handler_data[:endpoint_class].new.call(request)
399
+ elsif handler_data[:block]
400
+ app_ref.instance_exec(request, &handler_data[:block])
401
+ end
402
+ }
403
+ )
404
+ end
405
+ end
406
+
407
+ def register_doc_routes
408
+ generator = OpenAPI::Generator.new(**@openapi_config)
409
+
410
+ @router.routes.each do |route|
411
+ match = @router.match(route[:method], route[:path])
412
+ next unless match
413
+ handler = match[:handler]
414
+ generator.add_route(
415
+ method: route[:method], path: route[:path],
416
+ request_schema: handler[:request_schema],
417
+ response_schema: handler[:response_schema]
418
+ )
419
+ end
420
+
421
+ openapi_json = generator.to_json
422
+ @router.add("GET", "/openapi.json", {
423
+ block: -> (_req) { [200, { "content-type" => "application/json" }, [openapi_json]] },
424
+ request_schema: nil, response_schema: nil, middleware: []
425
+ })
426
+
427
+ @router.add("GET", "/docs", {
428
+ block: -> (_req) { OpenAPI::UI.rack_response("/openapi.json") },
429
+ request_schema: nil, response_schema: nil, middleware: []
430
+ })
431
+
432
+ if @docs_config && @docs_config[:redoc]
433
+ @router.add("GET", "/redoc", {
434
+ block: -> (_req) { OpenAPI::UI.redoc_response("/openapi.json") },
435
+ request_schema: nil, response_schema: nil, middleware: []
436
+ })
437
+ end
438
+ end
439
+
440
+ def register_metrics_route
441
+ metrics_ref = @metrics
442
+ @router.add("GET", "/metrics", {
443
+ block: -> (_req) { [200, { "content-type" => "text/plain; version=0.0.4" }, [metrics_ref.to_prometheus]] },
444
+ request_schema: nil, response_schema: nil, middleware: []
445
+ })
446
+ end
447
+
448
+ def add_route(method, path, request: nil, response: nil, **metadata, &block)
449
+ full_path = "#{@group_prefix}#{path}"
450
+ merged_metadata = @group_metadata.merge(metadata)
451
+ handler = {
452
+ block: block,
453
+ request_schema: request,
454
+ response_schema: response,
455
+ middleware: @group_middleware.dup
456
+ }
457
+ @router.add(method, full_path, handler, **merged_metadata)
458
+ end
459
+
460
+ def handle_request(env)
461
+ request = Request.new(env)
462
+ env["whoosh.logger"] = @logger
463
+ env["whoosh.storage"] = @di.resolve(:storage) rescue nil
464
+ match = @router.match(request.method, request.path)
465
+
466
+ return Response.not_found unless match
467
+
468
+ request.path_params = match[:params]
469
+ handler = match[:handler]
470
+
471
+ # Validate request schema
472
+ if handler[:request_schema]
473
+ body = request.body || {}
474
+ result = handler[:request_schema].validate(body)
475
+ unless result.success?
476
+ return Response.error(Errors::ValidationError.new(result.errors))
477
+ end
478
+ request.instance_variable_set(:@body, result.data)
479
+ end
480
+
481
+ # Authenticate if route requires it
482
+ if match[:metadata] && match[:metadata][:auth]
483
+ raise Errors::UnauthorizedError, "No authenticator configured" unless @authenticator
484
+ auth_result = @authenticator.authenticate(request)
485
+ request.env["whoosh.auth"] = auth_result
486
+ end
487
+
488
+ # Rate limit check
489
+ if @rate_limiter_instance
490
+ key = request.env.dig("whoosh.auth", :key) || request.env["REMOTE_ADDR"] || "anonymous"
491
+ @rate_limiter_instance.check!(key, request.path)
492
+ end
493
+
494
+ # Call handler
495
+ if handler[:endpoint_class]
496
+ # Class-based endpoint
497
+ endpoint = handler[:endpoint_class].new
498
+ context = Endpoint::Context.new(self, request)
499
+ result = endpoint.call(context.request)
500
+ else
501
+ # Inline block endpoint
502
+ block = handler[:block]
503
+ block_params = block.parameters
504
+ kwargs_names = block_params.select { |type, _| type == :keyreq || type == :key }.map(&:last)
505
+ kwargs = @di.inject_for(kwargs_names, request: request)
506
+
507
+ result = if kwargs.any? && block_params.any? { |type, _| type == :req || type == :opt }
508
+ instance_exec(request, **kwargs, &block)
509
+ elsif kwargs.any?
510
+ instance_exec(**kwargs, &block)
511
+ elsif block_params.any? { |type, _| type == :req || type == :opt }
512
+ instance_exec(request, &block)
513
+ else
514
+ instance_exec(&block)
515
+ end
516
+ end
517
+
518
+ # Validate response schema (development only)
519
+ if handler[:response_schema] && !@config.production?
520
+ response_result = handler[:response_schema].validate(result)
521
+ unless response_result.success?
522
+ @logger.warn("response_validation_failed",
523
+ path: request.path,
524
+ errors: response_result.errors.map { |e| e[:message] }
525
+ )
526
+ end
527
+ end
528
+
529
+ if result.is_a?(Array) && result.length == 3 && result[0].is_a?(Integer)
530
+ result
531
+ else
532
+ Response.json(result)
533
+ end
534
+
535
+ rescue Errors::HttpError => e
536
+ Response.error(e)
537
+ rescue => e
538
+ @instrumentation.emit(:error, { error: e, path: request&.path, method: request&.method })
539
+ handle_error(e, request)
540
+ end
541
+
542
+ def handle_error(error, request)
543
+ # Check specific error handlers
544
+ handler = @error_handlers.find { |klass, _| error.is_a?(klass) }&.last
545
+ handler ||= @default_error_handler
546
+
547
+ if handler
548
+ result = handler.call(error, request)
549
+ Response.json(result, status: 500)
550
+ else
551
+ Response.error(error, production: @config.production?)
552
+ end
553
+ end
554
+
555
+ # --- DSL Builders ---
556
+
557
+ class AuthBuilder
558
+ def initialize
559
+ @strategies = {}
560
+ end
561
+
562
+ def api_key(header: "X-Api-Key", keys: {})
563
+ @strategies[:api_key] = Auth::ApiKey.new(keys: keys, header: header)
564
+ end
565
+
566
+ def jwt(secret:, algorithm: :hs256, expiry: 3600)
567
+ @strategies[:jwt] = Auth::Jwt.new(secret: secret, algorithm: algorithm, expiry: expiry)
568
+ end
569
+
570
+ def build
571
+ @strategies.values.first
572
+ end
573
+ end
574
+
575
+ class RateLimitBuilder
576
+ def initialize
577
+ @default_limit = 60
578
+ @default_period = 60
579
+ @rules = []
580
+ @tiers = []
581
+ @on_store_failure = :fail_open
582
+ end
583
+
584
+ def default(limit:, period:)
585
+ @default_limit = limit
586
+ @default_period = period
587
+ end
588
+
589
+ def rule(path, limit:, period:)
590
+ @rules << { path: path, limit: limit, period: period }
591
+ end
592
+
593
+ def tier(name, limit: nil, period: nil, unlimited: false)
594
+ @tiers << { name: name, limit: limit, period: period, unlimited: unlimited }
595
+ end
596
+
597
+ def on_store_failure(strategy)
598
+ @on_store_failure = strategy
599
+ end
600
+
601
+ def build
602
+ limiter = Auth::RateLimiter.new(
603
+ default_limit: @default_limit,
604
+ default_period: @default_period,
605
+ on_store_failure: @on_store_failure
606
+ )
607
+ @rules.each { |r| limiter.rule(r[:path], limit: r[:limit], period: r[:period]) }
608
+ @tiers.each { |t| limiter.tier(t[:name], limit: t[:limit], period: t[:period], unlimited: t[:unlimited]) }
609
+ limiter
610
+ end
611
+ end
612
+
613
+ class TokenTrackingBuilder
614
+ def initialize(tracker)
615
+ @tracker = tracker
616
+ end
617
+
618
+ def on_usage(&block)
619
+ @tracker.on_usage(&block)
620
+ end
621
+ end
622
+
623
+ class HealthCheckBuilder
624
+ attr_reader :probes
625
+ def initialize
626
+ @probes = {}
627
+ end
628
+ def probe(name, &block)
629
+ @probes[name] = block
630
+ end
631
+ end
632
+
633
+ class OpenAPIConfigBuilder
634
+ def initialize
635
+ @config = {}
636
+ end
637
+
638
+ def title(val)
639
+ @config[:title] = val
640
+ end
641
+
642
+ def version(val)
643
+ @config[:version] = val
644
+ end
645
+
646
+ def description(val)
647
+ @config[:description] = val
648
+ end
649
+
650
+ def to_h
651
+ @config
652
+ end
653
+ end
654
+ end
655
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module Auth
5
+ class AccessControl
6
+ def initialize
7
+ @roles = {}
8
+ end
9
+
10
+ def role(name, models: [])
11
+ @roles[name] = models.dup.freeze
12
+ end
13
+
14
+ def check!(role, model)
15
+ allowed = @roles[role]
16
+ unless allowed && allowed.include?(model)
17
+ raise Errors::ForbiddenError, "Model '#{model}' not allowed for role '#{role}'"
18
+ end
19
+ end
20
+
21
+ def models_for(role)
22
+ @roles[role] || []
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module Auth
5
+ class ApiKey
6
+ def initialize(keys: {}, header: "X-Api-Key")
7
+ @keys = keys.dup
8
+ @header = header
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def authenticate(request)
13
+ raw_value = request.headers[@header]
14
+ raise Errors::UnauthorizedError, "Missing API key" unless raw_value
15
+ key = raw_value.sub(/\ABearer\s+/i, "")
16
+ metadata = @keys[key]
17
+ raise Errors::UnauthorizedError, "Invalid API key" unless metadata
18
+ { key: key, **metadata }
19
+ end
20
+
21
+ def register_key(key, **metadata)
22
+ @mutex.synchronize { @keys[key] = metadata }
23
+ end
24
+
25
+ def revoke_key(key)
26
+ @mutex.synchronize { @keys.delete(key) }
27
+ end
28
+ end
29
+ end
30
+ end