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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/exe/whoosh +6 -0
- data/lib/whoosh/app.rb +655 -0
- data/lib/whoosh/auth/access_control.rb +26 -0
- data/lib/whoosh/auth/api_key.rb +30 -0
- data/lib/whoosh/auth/jwt.rb +88 -0
- data/lib/whoosh/auth/oauth2.rb +33 -0
- data/lib/whoosh/auth/rate_limiter.rb +86 -0
- data/lib/whoosh/auth/token_tracker.rb +40 -0
- data/lib/whoosh/cache/memory_store.rb +57 -0
- data/lib/whoosh/cache/redis_store.rb +72 -0
- data/lib/whoosh/cache.rb +26 -0
- data/lib/whoosh/cli/generators.rb +133 -0
- data/lib/whoosh/cli/main.rb +277 -0
- data/lib/whoosh/cli/project_generator.rb +172 -0
- data/lib/whoosh/config.rb +160 -0
- data/lib/whoosh/database.rb +47 -0
- data/lib/whoosh/dependency_injection.rb +103 -0
- data/lib/whoosh/endpoint.rb +79 -0
- data/lib/whoosh/env_loader.rb +46 -0
- data/lib/whoosh/errors.rb +68 -0
- data/lib/whoosh/http/response.rb +26 -0
- data/lib/whoosh/http.rb +73 -0
- data/lib/whoosh/instrumentation.rb +22 -0
- data/lib/whoosh/job.rb +24 -0
- data/lib/whoosh/jobs/memory_backend.rb +45 -0
- data/lib/whoosh/jobs/worker.rb +73 -0
- data/lib/whoosh/jobs.rb +50 -0
- data/lib/whoosh/logger.rb +62 -0
- data/lib/whoosh/mcp/client.rb +71 -0
- data/lib/whoosh/mcp/client_manager.rb +73 -0
- data/lib/whoosh/mcp/protocol.rb +39 -0
- data/lib/whoosh/mcp/server.rb +66 -0
- data/lib/whoosh/mcp/transport/sse.rb +26 -0
- data/lib/whoosh/mcp/transport/stdio.rb +33 -0
- data/lib/whoosh/metrics.rb +84 -0
- data/lib/whoosh/middleware/cors.rb +61 -0
- data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
- data/lib/whoosh/middleware/request_limit.rb +28 -0
- data/lib/whoosh/middleware/request_logger.rb +39 -0
- data/lib/whoosh/middleware/security_headers.rb +28 -0
- data/lib/whoosh/middleware/stack.rb +25 -0
- data/lib/whoosh/openapi/generator.rb +50 -0
- data/lib/whoosh/openapi/schema_converter.rb +48 -0
- data/lib/whoosh/openapi/ui.rb +62 -0
- data/lib/whoosh/paginate.rb +64 -0
- data/lib/whoosh/performance.rb +20 -0
- data/lib/whoosh/plugins/base.rb +42 -0
- data/lib/whoosh/plugins/registry.rb +139 -0
- data/lib/whoosh/request.rb +93 -0
- data/lib/whoosh/response.rb +39 -0
- data/lib/whoosh/router.rb +112 -0
- data/lib/whoosh/schema.rb +194 -0
- data/lib/whoosh/serialization/json.rb +73 -0
- data/lib/whoosh/serialization/msgpack.rb +51 -0
- data/lib/whoosh/serialization/negotiator.rb +37 -0
- data/lib/whoosh/serialization/protobuf.rb +43 -0
- data/lib/whoosh/shutdown.rb +30 -0
- data/lib/whoosh/storage/local.rb +24 -0
- data/lib/whoosh/storage/s3.rb +31 -0
- data/lib/whoosh/storage.rb +20 -0
- data/lib/whoosh/streaming/llm_stream.rb +51 -0
- data/lib/whoosh/streaming/sse.rb +61 -0
- data/lib/whoosh/streaming/stream_body.rb +59 -0
- data/lib/whoosh/streaming/websocket.rb +51 -0
- data/lib/whoosh/test.rb +70 -0
- data/lib/whoosh/types.rb +11 -0
- data/lib/whoosh/uploaded_file.rb +47 -0
- data/lib/whoosh/version.rb +5 -0
- data/lib/whoosh.rb +86 -0
- 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
|