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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/25-09-01-OPENAPI_IMPLEMENTATION.md +233 -0
  3. data/.claude/25-09-05-RESPONSE_SCHEMA.md +383 -0
  4. data/.claude/25-09-10-OPENAPI_PLAN.md +219 -0
  5. data/.claude/25-10-26-MIDDLEWARE_IMPLEMENTATION.md +230 -0
  6. data/.claude/25-10-26-MIDDLEWARE_PLAN.md +353 -0
  7. data/.claude/25-10-27-BACKGROUND_TASKS_ANALYSIS.md +325 -0
  8. data/.claude/25-10-27-DEPENDENCY_IMPLEMENTATION_SUMMARY.md +325 -0
  9. data/.claude/25-10-27-DEPENDENCY_INJECTION_PLAN.md +753 -0
  10. data/.claude/25-12-24-LIFECYCLE_HOOKS_PLAN.md +421 -0
  11. data/.claude/25-12-24-PUBLISHING_AND_DOGFOODING_PLAN.md +327 -0
  12. data/.claude/25-12-24-TEMPLATE_RENDERING_PLAN.md +704 -0
  13. data/.claude/DECISIONS.md +397 -0
  14. data/.claude/PROJECT_PLAN.md +80 -0
  15. data/.claude/TESTING_PLAN.md +285 -0
  16. data/.claude/TESTING_STATUS.md +157 -0
  17. data/.tool-versions +1 -0
  18. data/AGENTS.md +416 -0
  19. data/CHANGELOG.md +5 -0
  20. data/CODE_OF_CONDUCT.md +132 -0
  21. data/LICENSE.txt +21 -0
  22. data/README.md +660 -0
  23. data/Rakefile +10 -0
  24. data/docs +8 -0
  25. data/docs-site/.gitignore +3 -0
  26. data/docs-site/Gemfile +9 -0
  27. data/docs-site/app.rb +138 -0
  28. data/docs-site/content/essential/handler.md +156 -0
  29. data/docs-site/content/essential/lifecycle.md +161 -0
  30. data/docs-site/content/essential/middleware.md +201 -0
  31. data/docs-site/content/essential/openapi.md +155 -0
  32. data/docs-site/content/essential/routing.md +123 -0
  33. data/docs-site/content/essential/validation.md +166 -0
  34. data/docs-site/content/getting-started/at-glance.md +82 -0
  35. data/docs-site/content/getting-started/key-concepts.md +150 -0
  36. data/docs-site/content/getting-started/quick-start.md +127 -0
  37. data/docs-site/content/index.md +81 -0
  38. data/docs-site/content/patterns/async-operations.md +137 -0
  39. data/docs-site/content/patterns/background-tasks.md +143 -0
  40. data/docs-site/content/patterns/database.md +175 -0
  41. data/docs-site/content/patterns/dependencies.md +141 -0
  42. data/docs-site/content/patterns/deployment.md +212 -0
  43. data/docs-site/content/patterns/error-handling.md +184 -0
  44. data/docs-site/content/patterns/response-schema.md +159 -0
  45. data/docs-site/content/patterns/templates.md +193 -0
  46. data/docs-site/content/patterns/testing.md +218 -0
  47. data/docs-site/mise.toml +2 -0
  48. data/docs-site/public/css/style.css +234 -0
  49. data/docs-site/templates/layouts/docs.html.erb +28 -0
  50. data/docs-site/templates/page.html.erb +3 -0
  51. data/docs-site/templates/partials/_nav.html.erb +19 -0
  52. data/examples/background_tasks_demo.rb +159 -0
  53. data/examples/demo_middleware.rb +55 -0
  54. data/examples/demo_openapi.rb +63 -0
  55. data/examples/dependency_block_demo.rb +150 -0
  56. data/examples/dependency_cleanup_demo.rb +146 -0
  57. data/examples/dependency_injection_demo.rb +200 -0
  58. data/examples/lifecycle_demo.rb +57 -0
  59. data/examples/middleware_demo.rb +74 -0
  60. data/examples/templates/layouts/application.html.erb +66 -0
  61. data/examples/templates/todos/_todo.html.erb +15 -0
  62. data/examples/templates/todos/index.html.erb +12 -0
  63. data/examples/templates_demo.rb +87 -0
  64. data/lib/funapi/application.rb +521 -0
  65. data/lib/funapi/async.rb +57 -0
  66. data/lib/funapi/background_tasks.rb +52 -0
  67. data/lib/funapi/config.rb +23 -0
  68. data/lib/funapi/database/sequel/fibered_connection_pool.rb +87 -0
  69. data/lib/funapi/dependency_wrapper.rb +66 -0
  70. data/lib/funapi/depends.rb +138 -0
  71. data/lib/funapi/exceptions.rb +72 -0
  72. data/lib/funapi/middleware/base.rb +13 -0
  73. data/lib/funapi/middleware/cors.rb +23 -0
  74. data/lib/funapi/middleware/request_logger.rb +32 -0
  75. data/lib/funapi/middleware/trusted_host.rb +34 -0
  76. data/lib/funapi/middleware.rb +4 -0
  77. data/lib/funapi/openapi/schema_converter.rb +85 -0
  78. data/lib/funapi/openapi/spec_generator.rb +179 -0
  79. data/lib/funapi/router.rb +43 -0
  80. data/lib/funapi/schema.rb +65 -0
  81. data/lib/funapi/server/falcon.rb +38 -0
  82. data/lib/funapi/template_response.rb +17 -0
  83. data/lib/funapi/templates.rb +111 -0
  84. data/lib/funapi/version.rb +5 -0
  85. data/lib/funapi.rb +14 -0
  86. data/sig/fun_api.rbs +499 -0
  87. 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
@@ -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