funapi 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.
- checksums.yaml +7 -0
- data/.claude/25-09-01-OPENAPI_IMPLEMENTATION.md +233 -0
- data/.claude/25-09-05-RESPONSE_SCHEMA.md +383 -0
- data/.claude/25-09-10-OPENAPI_PLAN.md +219 -0
- data/.claude/25-10-26-MIDDLEWARE_IMPLEMENTATION.md +230 -0
- data/.claude/25-10-26-MIDDLEWARE_PLAN.md +353 -0
- data/.claude/25-10-27-BACKGROUND_TASKS_ANALYSIS.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_IMPLEMENTATION_SUMMARY.md +325 -0
- data/.claude/25-10-27-DEPENDENCY_INJECTION_PLAN.md +753 -0
- data/.claude/25-12-24-LIFECYCLE_HOOKS_PLAN.md +421 -0
- data/.claude/25-12-24-PUBLISHING_AND_DOGFOODING_PLAN.md +327 -0
- data/.claude/25-12-24-TEMPLATE_RENDERING_PLAN.md +704 -0
- data/.claude/DECISIONS.md +397 -0
- data/.claude/PROJECT_PLAN.md +80 -0
- data/.claude/TESTING_PLAN.md +285 -0
- data/.claude/TESTING_STATUS.md +157 -0
- data/.tool-versions +1 -0
- data/AGENTS.md +416 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +660 -0
- data/Rakefile +10 -0
- data/docs +8 -0
- data/docs-site/.gitignore +3 -0
- data/docs-site/Gemfile +9 -0
- data/docs-site/app.rb +138 -0
- data/docs-site/content/essential/handler.md +156 -0
- data/docs-site/content/essential/lifecycle.md +161 -0
- data/docs-site/content/essential/middleware.md +201 -0
- data/docs-site/content/essential/openapi.md +155 -0
- data/docs-site/content/essential/routing.md +123 -0
- data/docs-site/content/essential/validation.md +166 -0
- data/docs-site/content/getting-started/at-glance.md +82 -0
- data/docs-site/content/getting-started/key-concepts.md +150 -0
- data/docs-site/content/getting-started/quick-start.md +127 -0
- data/docs-site/content/index.md +81 -0
- data/docs-site/content/patterns/async-operations.md +137 -0
- data/docs-site/content/patterns/background-tasks.md +143 -0
- data/docs-site/content/patterns/database.md +175 -0
- data/docs-site/content/patterns/dependencies.md +141 -0
- data/docs-site/content/patterns/deployment.md +212 -0
- data/docs-site/content/patterns/error-handling.md +184 -0
- data/docs-site/content/patterns/response-schema.md +159 -0
- data/docs-site/content/patterns/templates.md +193 -0
- data/docs-site/content/patterns/testing.md +218 -0
- data/docs-site/mise.toml +2 -0
- data/docs-site/public/css/style.css +234 -0
- data/docs-site/templates/layouts/docs.html.erb +28 -0
- data/docs-site/templates/page.html.erb +3 -0
- data/docs-site/templates/partials/_nav.html.erb +19 -0
- data/examples/background_tasks_demo.rb +159 -0
- data/examples/demo_middleware.rb +55 -0
- data/examples/demo_openapi.rb +63 -0
- data/examples/dependency_block_demo.rb +150 -0
- data/examples/dependency_cleanup_demo.rb +146 -0
- data/examples/dependency_injection_demo.rb +200 -0
- data/examples/lifecycle_demo.rb +57 -0
- data/examples/middleware_demo.rb +74 -0
- data/examples/templates/layouts/application.html.erb +66 -0
- data/examples/templates/todos/_todo.html.erb +15 -0
- data/examples/templates/todos/index.html.erb +12 -0
- data/examples/templates_demo.rb +87 -0
- data/lib/funapi/application.rb +521 -0
- data/lib/funapi/async.rb +57 -0
- data/lib/funapi/background_tasks.rb +52 -0
- data/lib/funapi/config.rb +23 -0
- data/lib/funapi/database/sequel/fibered_connection_pool.rb +87 -0
- data/lib/funapi/dependency_wrapper.rb +66 -0
- data/lib/funapi/depends.rb +138 -0
- data/lib/funapi/exceptions.rb +72 -0
- data/lib/funapi/middleware/base.rb +13 -0
- data/lib/funapi/middleware/cors.rb +23 -0
- data/lib/funapi/middleware/request_logger.rb +32 -0
- data/lib/funapi/middleware/trusted_host.rb +34 -0
- data/lib/funapi/middleware.rb +4 -0
- data/lib/funapi/openapi/schema_converter.rb +85 -0
- data/lib/funapi/openapi/spec_generator.rb +179 -0
- data/lib/funapi/router.rb +43 -0
- data/lib/funapi/schema.rb +65 -0
- data/lib/funapi/server/falcon.rb +38 -0
- data/lib/funapi/template_response.rb +17 -0
- data/lib/funapi/templates.rb +111 -0
- data/lib/funapi/version.rb +5 -0
- data/lib/funapi.rb +14 -0
- data/sig/fun_api.rbs +499 -0
- metadata +220 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/server"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
require "protocol/rack"
|
|
7
|
+
require "dry-container"
|
|
8
|
+
require_relative "router"
|
|
9
|
+
require_relative "async"
|
|
10
|
+
require_relative "exceptions"
|
|
11
|
+
require_relative "schema"
|
|
12
|
+
require_relative "depends"
|
|
13
|
+
require_relative "dependency_wrapper"
|
|
14
|
+
require_relative "background_tasks"
|
|
15
|
+
require_relative "template_response"
|
|
16
|
+
require_relative "openapi/spec_generator"
|
|
17
|
+
|
|
18
|
+
module FunApi
|
|
19
|
+
class App
|
|
20
|
+
attr_reader :openapi_config, :container, :startup_hooks, :shutdown_hooks
|
|
21
|
+
|
|
22
|
+
def initialize(title: "FunApi Application", version: "1.0.0", description: "")
|
|
23
|
+
@router = Router.new
|
|
24
|
+
@middleware_stack = []
|
|
25
|
+
@container = Dry::Container.new
|
|
26
|
+
@startup_hooks = []
|
|
27
|
+
@shutdown_hooks = []
|
|
28
|
+
@openapi_config = {
|
|
29
|
+
title: title,
|
|
30
|
+
version: version,
|
|
31
|
+
description: description
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
yield self if block_given?
|
|
35
|
+
|
|
36
|
+
register_openapi_routes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_startup(&block)
|
|
40
|
+
raise ArgumentError, "on_startup requires a block" unless block_given?
|
|
41
|
+
|
|
42
|
+
@startup_hooks << block
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def on_shutdown(&block)
|
|
47
|
+
raise ArgumentError, "on_shutdown requires a block" unless block_given?
|
|
48
|
+
|
|
49
|
+
@shutdown_hooks << block
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_startup_hooks
|
|
54
|
+
@startup_hooks.each(&:call)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run_shutdown_hooks
|
|
58
|
+
@shutdown_hooks.each do |hook|
|
|
59
|
+
hook.call
|
|
60
|
+
rescue => e
|
|
61
|
+
warn "Shutdown hook failed: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def register(key, &block)
|
|
66
|
+
@container.register(key) do
|
|
67
|
+
if block.arity == 0
|
|
68
|
+
result = block.call
|
|
69
|
+
if result.is_a?(Array) && result.length == 2 && result[1].respond_to?(:call)
|
|
70
|
+
ManagedDependency.new(result[0], result[1])
|
|
71
|
+
else
|
|
72
|
+
SimpleDependency.new(result)
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
BlockDependency.new(block)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolve(key)
|
|
81
|
+
@container.resolve(key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get(path, query: nil, response_schema: nil, depends: nil, &blk)
|
|
85
|
+
add_route("GET", path, query: query, response_schema: response_schema, depends: depends, &blk)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def post(path, body: nil, query: nil, response_schema: nil, depends: nil, &blk)
|
|
89
|
+
add_route("POST", path, body: body, query: query, response_schema: response_schema, depends: depends, &blk)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def put(path, body: nil, query: nil, response_schema: nil, depends: nil, &blk)
|
|
93
|
+
add_route("PUT", path, body: body, query: query, response_schema: response_schema, depends: depends, &blk)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def patch(path, body: nil, query: nil, response_schema: nil, depends: nil, &blk)
|
|
97
|
+
add_route("PATCH", path, body: body, query: query, response_schema: response_schema, depends: depends, &blk)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def delete(path, query: nil, response_schema: nil, depends: nil, &blk)
|
|
101
|
+
add_route("DELETE", path, query: query, response_schema: response_schema, depends: depends, &blk)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def use(middleware, *args, &block)
|
|
105
|
+
@middleware_stack << [middleware, args, block]
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_cors(allow_origins: ["*"], allow_methods: ["*"], allow_headers: ["*"],
|
|
110
|
+
expose_headers: [], max_age: 600, allow_credentials: false)
|
|
111
|
+
require_relative "middleware/cors"
|
|
112
|
+
use FunApi::Middleware::Cors,
|
|
113
|
+
allow_origins: allow_origins,
|
|
114
|
+
allow_methods: allow_methods,
|
|
115
|
+
allow_headers: allow_headers,
|
|
116
|
+
expose_headers: expose_headers,
|
|
117
|
+
max_age: max_age,
|
|
118
|
+
allow_credentials: allow_credentials
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def add_trusted_host(allowed_hosts:)
|
|
122
|
+
require_relative "middleware/trusted_host"
|
|
123
|
+
use FunApi::Middleware::TrustedHost, allowed_hosts: allowed_hosts
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_request_logger(logger: nil, level: :info)
|
|
127
|
+
require_relative "middleware/request_logger"
|
|
128
|
+
use FunApi::Middleware::RequestLogger, logger: logger, level: level
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def add_gzip
|
|
132
|
+
use Rack::Deflater, if: lambda { |_env, _status, headers, _body|
|
|
133
|
+
headers["content-type"]&.start_with?("application/json")
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def call(env)
|
|
138
|
+
app = build_middleware_chain
|
|
139
|
+
app.call(env)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Run the app with Falcon
|
|
143
|
+
# def run!(host: 'localhost', port: 9292, **options)
|
|
144
|
+
# puts "🚀 FunAPI server starting on http://#{host}:#{port}"
|
|
145
|
+
# puts "📚 Environment: #{options[:environment] || 'development'}"
|
|
146
|
+
# puts '⚡ Press Ctrl+C to stop'
|
|
147
|
+
# puts
|
|
148
|
+
|
|
149
|
+
# rack_app = self
|
|
150
|
+
|
|
151
|
+
# Async do |task|
|
|
152
|
+
# # Create endpoint
|
|
153
|
+
# endpoint = Async::HTTP::Endpoint.parse(
|
|
154
|
+
# "http://#{host}:#{port}",
|
|
155
|
+
# reuse_address: true
|
|
156
|
+
# )
|
|
157
|
+
|
|
158
|
+
# # Wrap Rack app for async-http
|
|
159
|
+
# app = Protocol::Rack::Adapter.new(rack_app)
|
|
160
|
+
|
|
161
|
+
# # Create server
|
|
162
|
+
# server = Async::HTTP::Server.new(app, endpoint)
|
|
163
|
+
|
|
164
|
+
# # Handle graceful shutdown
|
|
165
|
+
# Signal.trap('INT') do
|
|
166
|
+
# puts "\n👋 Shutting down gracefully..."
|
|
167
|
+
# task.stop
|
|
168
|
+
# end
|
|
169
|
+
|
|
170
|
+
# Signal.trap('TERM') do
|
|
171
|
+
# puts "\n👋 Shutting down gracefully..."
|
|
172
|
+
# task.stop
|
|
173
|
+
# end
|
|
174
|
+
|
|
175
|
+
# # Run the server
|
|
176
|
+
# server.run
|
|
177
|
+
# end
|
|
178
|
+
# end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def add_route(verb, path, body: nil, query: nil, response_schema: nil, depends: nil, &blk)
|
|
183
|
+
metadata = {
|
|
184
|
+
body_schema: body,
|
|
185
|
+
query_schema: query,
|
|
186
|
+
response_schema: response_schema,
|
|
187
|
+
dependencies: normalize_dependencies(depends)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@router.add(verb, path, metadata: metadata) do |req, path_params|
|
|
191
|
+
handle_async_route(req, path_params, body, query, response_schema, metadata[:dependencies], &blk)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_async_route(req, path_params, body_schema, query_schema, response_schema, dependencies, &blk)
|
|
196
|
+
current_task = Async::Task.current
|
|
197
|
+
Fiber[:async_task] = current_task
|
|
198
|
+
cleanup_objects = []
|
|
199
|
+
background_tasks = BackgroundTasks.new(current_task)
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
input = {
|
|
203
|
+
path: path_params,
|
|
204
|
+
query: req.params,
|
|
205
|
+
body: parse_body(req)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
input[:query] = Schema.validate(query_schema, input[:query], location: "query") if query_schema
|
|
209
|
+
|
|
210
|
+
input[:body] = Schema.validate(body_schema, input[:body], location: "body") if body_schema
|
|
211
|
+
|
|
212
|
+
resolved_deps, cleanup_objects = resolve_dependencies(dependencies, input, req, current_task)
|
|
213
|
+
|
|
214
|
+
# standard:disable Style/HashSlice
|
|
215
|
+
handler_params = blk.parameters.select { |type, _| %i[keyreq key].include?(type) }.map(&:last)
|
|
216
|
+
# standard:enable Style/HashSlice
|
|
217
|
+
resolved_deps[:background] = background_tasks if handler_params.include?(:background)
|
|
218
|
+
|
|
219
|
+
payload, status = blk.call(input, req, current_task, **resolved_deps)
|
|
220
|
+
|
|
221
|
+
if payload.is_a?(TemplateResponse)
|
|
222
|
+
background_tasks.execute
|
|
223
|
+
return payload.to_response
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
payload = normalize_payload(payload)
|
|
227
|
+
|
|
228
|
+
payload = Schema.validate_response(response_schema, payload) if response_schema
|
|
229
|
+
|
|
230
|
+
background_tasks.execute
|
|
231
|
+
|
|
232
|
+
[
|
|
233
|
+
status || 200,
|
|
234
|
+
{"content-type" => "application/json"},
|
|
235
|
+
[JSON.dump(payload)]
|
|
236
|
+
]
|
|
237
|
+
rescue ValidationError => e
|
|
238
|
+
e.to_response
|
|
239
|
+
rescue HTTPException => e
|
|
240
|
+
e.to_response
|
|
241
|
+
ensure
|
|
242
|
+
cleanup_objects.each do |wrapper|
|
|
243
|
+
wrapper.cleanup
|
|
244
|
+
rescue => e
|
|
245
|
+
warn "Dependency cleanup failed: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
Fiber[:async_task] = nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def build_middleware_chain
|
|
252
|
+
app = @router
|
|
253
|
+
|
|
254
|
+
@middleware_stack.reverse_each do |middleware, args, block|
|
|
255
|
+
app = if args.length == 1 && args.first.is_a?(Hash) && args.first.keys.all? { |k| k.is_a?(Symbol) }
|
|
256
|
+
middleware.new(app, **args.first, &block)
|
|
257
|
+
else
|
|
258
|
+
middleware.new(app, *args, &block)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
app
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# def handle_request(env)
|
|
266
|
+
# request = Rack::Request.new(env)
|
|
267
|
+
# route = @router.match(request.request_method, request.path_info)
|
|
268
|
+
#
|
|
269
|
+
# return [404, {}, ['Not Found']] unless route
|
|
270
|
+
#
|
|
271
|
+
# # Build input from request
|
|
272
|
+
# input = build_input(request, route.path_params)
|
|
273
|
+
#
|
|
274
|
+
# # Validate with contract if present
|
|
275
|
+
# if route.contract
|
|
276
|
+
# result = route.contract.call(input)
|
|
277
|
+
#
|
|
278
|
+
# if result.failure?
|
|
279
|
+
# return [422,
|
|
280
|
+
# { 'content-type' => 'application/json' },
|
|
281
|
+
# [JSON.generate(errors: result.errors.to_h)]]
|
|
282
|
+
# end
|
|
283
|
+
#
|
|
284
|
+
# input = result.to_h
|
|
285
|
+
# end
|
|
286
|
+
#
|
|
287
|
+
# # Call handler - returns [body, status] or [body, status, headers]
|
|
288
|
+
# response = route.handler.call(input, request)
|
|
289
|
+
# normalize_response(response)
|
|
290
|
+
# end
|
|
291
|
+
#
|
|
292
|
+
# def build_input(request, path_params)
|
|
293
|
+
# {
|
|
294
|
+
# path: path_params,
|
|
295
|
+
# query: request.GET, # Query params only
|
|
296
|
+
# body: parse_body(request),
|
|
297
|
+
# headers: extract_headers(request.env)
|
|
298
|
+
# }
|
|
299
|
+
# end
|
|
300
|
+
# Optional body parsing helper
|
|
301
|
+
|
|
302
|
+
def parse_body(request)
|
|
303
|
+
return nil unless request.body
|
|
304
|
+
|
|
305
|
+
content_type = request.content_type
|
|
306
|
+
body = request.body.read
|
|
307
|
+
request.body.rewind
|
|
308
|
+
|
|
309
|
+
case content_type
|
|
310
|
+
when %r{application/json}
|
|
311
|
+
begin
|
|
312
|
+
JSON.parse(body, symbolize_names: true)
|
|
313
|
+
rescue
|
|
314
|
+
{}
|
|
315
|
+
end
|
|
316
|
+
when %r{application/x-www-form-urlencoded}
|
|
317
|
+
request.POST
|
|
318
|
+
else
|
|
319
|
+
body
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def extract_headers(env)
|
|
324
|
+
env.select { |k, _v| k.start_with?("HTTP_") }
|
|
325
|
+
.transform_keys { |k| k.sub("HTTP_", "").downcase }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def normalize_response(response)
|
|
329
|
+
case response
|
|
330
|
+
in [body, status, headers]
|
|
331
|
+
[status, headers, [serialize_body(body)]]
|
|
332
|
+
in [body, status]
|
|
333
|
+
[status, default_headers(body), [serialize_body(body)]]
|
|
334
|
+
in [body]
|
|
335
|
+
[200, default_headers(body), [serialize_body(body)]]
|
|
336
|
+
else
|
|
337
|
+
[200, default_headers(response), [serialize_body(response)]]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def serialize_body(body)
|
|
342
|
+
case body
|
|
343
|
+
when String then body
|
|
344
|
+
when Hash, Array then JSON.generate(body)
|
|
345
|
+
else body.to_s
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def default_headers(body)
|
|
350
|
+
case body
|
|
351
|
+
when Hash, Array
|
|
352
|
+
{"content-type" => "application/json"}
|
|
353
|
+
else
|
|
354
|
+
{"content-type" => "text/plain"}
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def normalize_payload(payload)
|
|
359
|
+
return payload unless payload
|
|
360
|
+
|
|
361
|
+
if payload.is_a?(Array)
|
|
362
|
+
payload.map { |item| normalize_single_payload(item) }
|
|
363
|
+
else
|
|
364
|
+
normalize_single_payload(payload)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def normalize_single_payload(item)
|
|
369
|
+
if item.respond_to?(:to_h) && item.class.name&.include?("Dry::Schema::Result")
|
|
370
|
+
item.to_h
|
|
371
|
+
else
|
|
372
|
+
item
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def normalize_dependencies(depends)
|
|
377
|
+
return {} if depends.nil?
|
|
378
|
+
|
|
379
|
+
normalized = {}
|
|
380
|
+
|
|
381
|
+
case depends
|
|
382
|
+
when Array
|
|
383
|
+
depends.each do |dep_name|
|
|
384
|
+
sym = dep_name.to_sym
|
|
385
|
+
normalized[sym] = {type: :container, key: sym}
|
|
386
|
+
end
|
|
387
|
+
when Hash
|
|
388
|
+
depends.each do |key, value|
|
|
389
|
+
normalized[key.to_sym] = case value
|
|
390
|
+
when Depends
|
|
391
|
+
{type: :depends, callable: value}
|
|
392
|
+
when Symbol
|
|
393
|
+
{type: :container, key: value}
|
|
394
|
+
when Proc, Method
|
|
395
|
+
{type: :depends, callable: Depends.new(value)}
|
|
396
|
+
when nil
|
|
397
|
+
{type: :container, key: key.to_sym}
|
|
398
|
+
else
|
|
399
|
+
unless value.respond_to?(:call)
|
|
400
|
+
raise ArgumentError, "Dependency must be callable, Depends, Symbol, or nil for #{key}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
{type: :depends, callable: Depends.new(value)}
|
|
404
|
+
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
else
|
|
408
|
+
raise ArgumentError, "depends must be an Array or Hash"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
normalized
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def resolve_dependencies(dependencies, input, req, task)
|
|
415
|
+
return [{}, []] if dependencies.nil? || dependencies.empty?
|
|
416
|
+
|
|
417
|
+
cache = {}
|
|
418
|
+
cleanup_objects = []
|
|
419
|
+
|
|
420
|
+
context = {
|
|
421
|
+
input: input,
|
|
422
|
+
req: req,
|
|
423
|
+
task: task,
|
|
424
|
+
container: @container
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
resolved = dependencies.transform_values do |dep_info|
|
|
428
|
+
case dep_info[:type]
|
|
429
|
+
when :container
|
|
430
|
+
cache_key = "container:#{dep_info[:key]}"
|
|
431
|
+
if cache.key?(cache_key)
|
|
432
|
+
cache[cache_key][:resource]
|
|
433
|
+
else
|
|
434
|
+
dependency_wrapper = @container.resolve(dep_info[:key])
|
|
435
|
+
resource = dependency_wrapper.call
|
|
436
|
+
cache[cache_key] = {resource: resource, wrapper: dependency_wrapper}
|
|
437
|
+
cleanup_objects << dependency_wrapper
|
|
438
|
+
resource
|
|
439
|
+
end
|
|
440
|
+
when :depends
|
|
441
|
+
result, cleanup = dep_info[:callable].call(context, cache)
|
|
442
|
+
cleanup_objects << ManagedDependency.new(result, cleanup) if cleanup
|
|
443
|
+
result
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
[resolved, cleanup_objects]
|
|
448
|
+
rescue => e
|
|
449
|
+
raise HTTPException.new(
|
|
450
|
+
status_code: 500,
|
|
451
|
+
detail: "Dependency resolution failed: #{e.message}"
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def register_openapi_routes
|
|
456
|
+
@router.add("GET", "/openapi.json", metadata: {internal: true}) do |_req, _path_params|
|
|
457
|
+
spec = generate_openapi_spec
|
|
458
|
+
[
|
|
459
|
+
200,
|
|
460
|
+
{"content-type" => "application/json"},
|
|
461
|
+
[JSON.dump(spec)]
|
|
462
|
+
]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
@router.add("GET", "/docs", metadata: {internal: true}) do |_req, _path_params|
|
|
466
|
+
html = swagger_ui_html
|
|
467
|
+
[
|
|
468
|
+
200,
|
|
469
|
+
{"content-type" => "text/html"},
|
|
470
|
+
[html]
|
|
471
|
+
]
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def generate_openapi_spec
|
|
476
|
+
generator = OpenAPI::SpecGenerator.new(@router.routes, info: @openapi_config)
|
|
477
|
+
generator.generate
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def swagger_ui_html
|
|
481
|
+
<<~HTML
|
|
482
|
+
<!DOCTYPE html>
|
|
483
|
+
<html lang="en">
|
|
484
|
+
<head>
|
|
485
|
+
<meta charset="UTF-8">
|
|
486
|
+
<base href="/" />
|
|
487
|
+
<title>#{@openapi_config[:title]} - Swagger UI</title>
|
|
488
|
+
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
|
|
489
|
+
<style>
|
|
490
|
+
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
|
491
|
+
*, *:before, *:after { box-sizing: inherit; }
|
|
492
|
+
body { margin:0; padding:0; }
|
|
493
|
+
</style>
|
|
494
|
+
</head>
|
|
495
|
+
<body>
|
|
496
|
+
<div id="swagger-ui"></div>
|
|
497
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
498
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
|
499
|
+
<script>
|
|
500
|
+
window.onload = function() {
|
|
501
|
+
window.ui = SwaggerUIBundle({
|
|
502
|
+
url: "/openapi.json",
|
|
503
|
+
dom_id: '#swagger-ui',
|
|
504
|
+
deepLinking: true,
|
|
505
|
+
presets: [
|
|
506
|
+
SwaggerUIBundle.presets.apis,
|
|
507
|
+
SwaggerUIStandalonePreset
|
|
508
|
+
],
|
|
509
|
+
plugins: [
|
|
510
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
|
511
|
+
],
|
|
512
|
+
layout: "StandaloneLayout"
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
</script>
|
|
516
|
+
</body>
|
|
517
|
+
</html>
|
|
518
|
+
HTML
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
data/lib/funapi/async.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# # frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# module FunApi
|
|
4
|
+
# # Async utilities for concurrent execution within route handlers
|
|
5
|
+
# module AsyncHelpers
|
|
6
|
+
# class NoAsyncContextError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# # Execute multiple async operations concurrently
|
|
9
|
+
# # Returns a hash with the same keys, values resolved from the callables
|
|
10
|
+
# def concurrent(**tasks)
|
|
11
|
+
# # For now, execute sequentially - we can optimize this later
|
|
12
|
+
# results = {}
|
|
13
|
+
# tasks.each do |key, callable|
|
|
14
|
+
# results[key] = callable.call
|
|
15
|
+
# end
|
|
16
|
+
# results
|
|
17
|
+
# end
|
|
18
|
+
|
|
19
|
+
# # Execute multiple async operations concurrently with block syntax
|
|
20
|
+
# def concurrent_block
|
|
21
|
+
# # For now, just execute the block
|
|
22
|
+
# yield(MockTask.new)
|
|
23
|
+
# end
|
|
24
|
+
|
|
25
|
+
# # Set a timeout for an async operation
|
|
26
|
+
# def timeout(_duration, &block)
|
|
27
|
+
# # For now, just execute without timeout
|
|
28
|
+
# block.call
|
|
29
|
+
# end
|
|
30
|
+
|
|
31
|
+
# # Access the current async task (for advanced usage)
|
|
32
|
+
# def current_task
|
|
33
|
+
# Fiber[:async_task] || MockTask.new
|
|
34
|
+
# end
|
|
35
|
+
|
|
36
|
+
# # Mock task for development
|
|
37
|
+
# class MockTask
|
|
38
|
+
# def async(&block)
|
|
39
|
+
# MockFiber.new(&block)
|
|
40
|
+
# end
|
|
41
|
+
|
|
42
|
+
# def sleep(duration)
|
|
43
|
+
# Kernel.sleep(duration)
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
|
|
47
|
+
# class MockFiber
|
|
48
|
+
# def initialize(&block)
|
|
49
|
+
# @result = block.call
|
|
50
|
+
# end
|
|
51
|
+
|
|
52
|
+
# def wait
|
|
53
|
+
# @result
|
|
54
|
+
# end
|
|
55
|
+
# end
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunApi
|
|
4
|
+
class BackgroundTasks
|
|
5
|
+
def initialize(task)
|
|
6
|
+
@task = task
|
|
7
|
+
@tasks = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def add_task(callable, *args, **kwargs)
|
|
11
|
+
@tasks << {callable: callable, args: args, kwargs: kwargs}
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute
|
|
16
|
+
return if @tasks.empty?
|
|
17
|
+
|
|
18
|
+
@tasks.each do |task_def|
|
|
19
|
+
callable = task_def[:callable]
|
|
20
|
+
args = task_def[:args]
|
|
21
|
+
kwargs = task_def[:kwargs]
|
|
22
|
+
|
|
23
|
+
@task.async do
|
|
24
|
+
if callable.respond_to?(:call)
|
|
25
|
+
if kwargs.empty?
|
|
26
|
+
callable.call(*args)
|
|
27
|
+
else
|
|
28
|
+
callable.call(*args, **kwargs)
|
|
29
|
+
end
|
|
30
|
+
elsif callable.is_a?(Symbol)
|
|
31
|
+
raise ArgumentError, "Cannot call Symbol #{callable} without a context object"
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "Task must be callable or Symbol, got #{callable.class}"
|
|
34
|
+
end
|
|
35
|
+
rescue => e
|
|
36
|
+
warn "Background task failed: #{e.class} - #{e.message}"
|
|
37
|
+
warn e.backtrace.first(3).join("\n") if e.backtrace
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@task.children.each(&:wait)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def empty?
|
|
45
|
+
@tasks.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def size
|
|
49
|
+
@tasks.size
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunApi
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :bind, :port, :env, :workers, :tls, :rack_app
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
bind: "https://localhost", # Falcon defaults to HTTPS in dev
|
|
9
|
+
port: 9292,
|
|
10
|
+
env: ENV.fetch("RACK_ENV", "development"),
|
|
11
|
+
workers: nil, # let falcon default; can pass via --count
|
|
12
|
+
tls: {}, # {cert:, key:} if you want to override
|
|
13
|
+
rack_app: nil
|
|
14
|
+
)
|
|
15
|
+
@bind = bind
|
|
16
|
+
@port = port
|
|
17
|
+
@env = env
|
|
18
|
+
@workers = workers
|
|
19
|
+
@tls = tls
|
|
20
|
+
@rack_app = rack_app
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|