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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Taken from https://github.com/umbrellio/umbrellio-sequel-plugins?tab=readme-ov-file#fibered-connection-pool
5
+ # Released under MIT License.
6
+ # Created by Team Umbrellio.
7
+
8
+ require "async"
9
+ require "async/notification"
10
+
11
+ class Sequel::FiberedConnectionPool < Sequel::ConnectionPool
12
+ def initialize(db, opts = Sequel::OPTS)
13
+ super
14
+
15
+ @max_connections = opts[:max_connections]
16
+ @available_connections = []
17
+ @notification = Async::Notification.new
18
+ @size = 0
19
+ end
20
+
21
+ def hold(*)
22
+ connection = wait_for_connection
23
+ return connection unless block_given?
24
+
25
+ begin
26
+ yield connection
27
+ rescue Sequel::DatabaseDisconnectError, *@error_classes => error
28
+ if disconnect_error?(error)
29
+ disconnect_connection(connection)
30
+ connection = nil
31
+ @size -= 1
32
+ end
33
+ raise
34
+ ensure
35
+ if connection
36
+ @available_connections.push(connection)
37
+ @notification.signal if Async::Task.current?
38
+ end
39
+ end
40
+ end
41
+
42
+ def disconnect(*)
43
+ @available_connections.each(&:close)
44
+ @available_connections.clear
45
+
46
+ @size = 0
47
+ end
48
+
49
+ attr_reader :size
50
+
51
+ private
52
+
53
+ def wait_for_connection
54
+ until (connection = find_or_create_connection)
55
+ @notification.wait
56
+ end
57
+
58
+ connection
59
+ end
60
+
61
+ def find_or_create_connection
62
+ if (connection = @available_connections.shift)
63
+ return connection
64
+ end
65
+
66
+ if @max_connections.nil? || @size < @max_connections
67
+ connection = make_new(:default)
68
+ @size += 1
69
+
70
+ return connection
71
+ end
72
+
73
+ nil
74
+ end
75
+ end
76
+
77
+ module Sequel::ConnectionPoolPatch
78
+ def connection_pool_class(*)
79
+ Sequel.current.is_a?(Fiber) ? Sequel::FiberedConnectionPool : super
80
+ end
81
+ end
82
+
83
+ class Sequel::ConnectionPool
84
+ class << self
85
+ prepend Sequel::ConnectionPoolPatch
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunApi
4
+ class SimpleDependency
5
+ attr_reader :resource
6
+
7
+ def initialize(resource)
8
+ @resource = resource
9
+ end
10
+
11
+ def call
12
+ @resource
13
+ end
14
+
15
+ def cleanup
16
+ end
17
+ end
18
+
19
+ class ManagedDependency
20
+ attr_reader :resource, :cleanup_proc
21
+
22
+ def initialize(resource, cleanup_proc)
23
+ @resource = resource
24
+ @cleanup_proc = cleanup_proc
25
+ end
26
+
27
+ def call
28
+ @resource
29
+ end
30
+
31
+ def cleanup
32
+ @cleanup_proc&.call
33
+ end
34
+ end
35
+
36
+ class BlockDependency
37
+ def initialize(block)
38
+ @block = block
39
+ @fiber = nil
40
+ @resource = nil
41
+ end
42
+
43
+ def call
44
+ return @resource if @fiber
45
+
46
+ @fiber = Fiber.new do
47
+ result = nil
48
+ @block.call(proc { |resource|
49
+ result = resource
50
+ Fiber.yield resource
51
+ })
52
+ result
53
+ end
54
+
55
+ @resource = @fiber.resume
56
+ @resource
57
+ end
58
+
59
+ def cleanup
60
+ return unless @fiber
61
+
62
+ @fiber.resume if @fiber.alive?
63
+ rescue FiberError
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunApi
4
+ class Depends
5
+ attr_reader :callable, :sub_dependencies
6
+
7
+ def initialize(callable, **sub_dependencies)
8
+ @callable = callable
9
+ @sub_dependencies = sub_dependencies
10
+
11
+ validate_callable!
12
+ end
13
+
14
+ def call(context, cache = {})
15
+ cache_key = object_id
16
+
17
+ return cache[cache_key] if cache.key?(cache_key)
18
+
19
+ resolved_deps, sub_cleanups = resolve_sub_dependencies(context, cache)
20
+ available_context = context.merge(resolved_deps)
21
+
22
+ result, cleanup = execute_callable(available_context)
23
+
24
+ combined_cleanup = if sub_cleanups.any? || cleanup
25
+ lambda {
26
+ sub_cleanups.each(&:call)
27
+ cleanup&.call
28
+ }
29
+ end
30
+
31
+ final_result = [result, combined_cleanup]
32
+ cache[cache_key] = final_result
33
+ final_result
34
+ end
35
+
36
+ private
37
+
38
+ def validate_callable!
39
+ return if @callable.respond_to?(:call)
40
+
41
+ raise ArgumentError, "Dependency must be callable (respond to :call)"
42
+ end
43
+
44
+ def resolve_sub_dependencies(context, cache)
45
+ cleanups = []
46
+
47
+ resolved = @sub_dependencies.transform_values do |dep|
48
+ result = if dep.is_a?(Depends)
49
+ dep.call(context, cache)
50
+ elsif dep.is_a?(Symbol)
51
+ container = context[:container]
52
+ unless container&.respond_to?(:resolve)
53
+ raise ArgumentError, "Cannot resolve symbol dependency :#{dep} without container in context"
54
+ end
55
+
56
+ wrapper = container.resolve(dep)
57
+ resource = wrapper.call
58
+ [resource, nil]
59
+
60
+ elsif dep.respond_to?(:call)
61
+ Depends.new(dep).call(context, cache)
62
+ else
63
+ [dep, nil]
64
+ end
65
+
66
+ resource, cleanup = if result.is_a?(Array) && result.length == 2
67
+ result
68
+ else
69
+ [result, nil]
70
+ end
71
+
72
+ cleanups << cleanup if cleanup
73
+ resource
74
+ end
75
+
76
+ [resolved, cleanups]
77
+ end
78
+
79
+ def extract_resource_and_cleanup_from_result(result)
80
+ if result.is_a?(Array) && result.length == 2 && result[1].respond_to?(:call)
81
+ result
82
+ else
83
+ [result, nil]
84
+ end
85
+ end
86
+
87
+ def execute_callable(context)
88
+ params = extract_params(context)
89
+
90
+ result = if params.any?
91
+ @callable.call(**params)
92
+ else
93
+ @callable.call
94
+ end
95
+
96
+ handle_result(result)
97
+ end
98
+
99
+ def extract_params(context)
100
+ parameters = callable_parameters
101
+ return {} unless parameters
102
+
103
+ params = {}
104
+
105
+ parameters.each do |type, name|
106
+ next unless %i[keyreq key].include?(type)
107
+
108
+ if context.key?(name)
109
+ params[name] = context[name]
110
+ elsif type == :keyreq
111
+ raise ArgumentError, "missing keyword: :#{name}"
112
+ end
113
+ end
114
+
115
+ params
116
+ end
117
+
118
+ def callable_parameters
119
+ if @callable.respond_to?(:parameters)
120
+ @callable.parameters
121
+ elsif @callable.respond_to?(:method)
122
+ @callable.method(:call).parameters
123
+ end
124
+ end
125
+
126
+ def handle_result(result)
127
+ if result.is_a?(Array) && result.length == 2 && result[1].respond_to?(:call)
128
+ result
129
+ else
130
+ [result, nil]
131
+ end
132
+ end
133
+ end
134
+
135
+ def self.Depends(callable, **sub_dependencies)
136
+ Depends.new(callable, **sub_dependencies)
137
+ end
138
+ end
@@ -0,0 +1,72 @@
1
+ module FunApi
2
+ class HTTPException < StandardError
3
+ attr_reader :status_code, :detail, :headers
4
+
5
+ def initialize(status_code:, detail: nil, headers: nil)
6
+ @status_code = status_code
7
+ @detail = detail || default_detail
8
+ @headers = headers || {}
9
+ super(@detail.to_s)
10
+ end
11
+
12
+ def to_response
13
+ [
14
+ status_code,
15
+ {"content-type" => "application/json"}.merge(headers),
16
+ [JSON.dump(detail: detail)]
17
+ ]
18
+ end
19
+
20
+ private
21
+
22
+ def default_detail
23
+ case status_code
24
+ when 400 then "Bad Request"
25
+ when 401 then "Unauthorized"
26
+ when 403 then "Forbidden"
27
+ when 404 then "Not Found"
28
+ when 422 then "Unprocessable Entity"
29
+ when 500 then "Internal Server Error"
30
+ else "Error"
31
+ end
32
+ end
33
+ end
34
+
35
+ class ValidationError < HTTPException
36
+ attr_reader :errors
37
+
38
+ def initialize(errors:, headers: nil)
39
+ @errors = errors
40
+ super(status_code: 422, detail: format_errors(errors), headers: headers)
41
+ end
42
+
43
+ def to_response
44
+ [
45
+ status_code,
46
+ {"content-type" => "application/json"}.merge(headers),
47
+ [JSON.dump(detail: detail)]
48
+ ]
49
+ end
50
+
51
+ private
52
+
53
+ def format_errors(errors)
54
+ errors.messages.map do |error|
55
+ {
56
+ loc: error.path.map(&:to_s),
57
+ msg: error.text,
58
+ type: "value_error"
59
+ }
60
+ end
61
+ end
62
+ end
63
+
64
+ class TemplateNotFoundError < StandardError
65
+ attr_reader :template_name
66
+
67
+ def initialize(template_name)
68
+ @template_name = template_name
69
+ super("Template not found: #{template_name}")
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ module FunApi
2
+ module Middleware
3
+ class Base
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ @app.call(env)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ module FunApi
2
+ module Middleware
3
+ class Cors
4
+ def self.new(app, allow_origins: ["*"], allow_methods: ["*"],
5
+ allow_headers: ["*"], expose_headers: [],
6
+ max_age: 600, allow_credentials: false)
7
+ require "rack/cors"
8
+
9
+ Rack::Cors.new(app) do |config|
10
+ config.allow do |allow|
11
+ allow.origins(*allow_origins)
12
+ allow.resource "*",
13
+ methods: allow_methods,
14
+ headers: allow_headers,
15
+ expose: expose_headers,
16
+ max_age: max_age,
17
+ credentials: allow_credentials
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ require "logger"
2
+
3
+ module FunApi
4
+ module Middleware
5
+ class RequestLogger
6
+ def initialize(app, **options)
7
+ @app = app
8
+ @logger = options[:logger] || Logger.new($stdout)
9
+ @level = options[:level] || :info
10
+ end
11
+
12
+ def call(env)
13
+ start = Time.now
14
+ status, headers, body = @app.call(env)
15
+ duration = Time.now - start
16
+
17
+ log_request(env, status, duration)
18
+
19
+ [status, headers, body]
20
+ end
21
+
22
+ private
23
+
24
+ def log_request(env, status, duration)
25
+ request = Rack::Request.new(env)
26
+ @logger.send(@level,
27
+ "#{request.request_method} #{request.path} " \
28
+ "#{status} #{(duration * 1000).round(2)}ms")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ module FunApi
2
+ module Middleware
3
+ class TrustedHost
4
+ def initialize(app, **options)
5
+ @app = app
6
+ @allowed_hosts = Array(options[:allowed_hosts] || [])
7
+ end
8
+
9
+ def call(env)
10
+ host = env["HTTP_HOST"]&.split(":")&.first
11
+
12
+ unless host_allowed?(host)
13
+ return [
14
+ 400,
15
+ {"content-type" => "application/json"},
16
+ [JSON.dump(detail: "Invalid host header")]
17
+ ]
18
+ end
19
+
20
+ @app.call(env)
21
+ end
22
+
23
+ private
24
+
25
+ def host_allowed?(host)
26
+ return true if @allowed_hosts.empty?
27
+
28
+ @allowed_hosts.any? do |pattern|
29
+ pattern.is_a?(Regexp) ? pattern.match?(host) : pattern == host
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,4 @@
1
+ require_relative "middleware/base"
2
+ require_relative "middleware/cors"
3
+ require_relative "middleware/trusted_host"
4
+ require_relative "middleware/request_logger"
@@ -0,0 +1,85 @@
1
+ module FunApi
2
+ module OpenAPI
3
+ class SchemaConverter
4
+ def self.to_json_schema(dry_schema, schema_name = nil)
5
+ return nil unless dry_schema
6
+
7
+ if dry_schema.is_a?(Array) && dry_schema.length == 1
8
+ return {
9
+ type: "array",
10
+ items: to_json_schema(dry_schema.first, schema_name)
11
+ }
12
+ end
13
+
14
+ properties = {}
15
+ required = []
16
+
17
+ dry_schema.rules.each do |key, rule|
18
+ field_name = key.to_s
19
+ field_info = extract_field_info(rule)
20
+
21
+ properties[field_name] = field_info[:schema]
22
+ required << field_name if field_info[:required]
23
+ end
24
+
25
+ schema = {
26
+ type: "object",
27
+ properties: properties
28
+ }
29
+
30
+ schema[:required] = required unless required.empty?
31
+ schema
32
+ end
33
+
34
+ def self.extract_field_info(rule)
35
+ rule_str = rule.to_s
36
+ is_required = rule.class.name.include?("And")
37
+
38
+ type_info = if rule_str.include?("array?")
39
+ items_type = if rule_str.include?("str?")
40
+ {type: "string"}
41
+ elsif rule_str.include?("int?")
42
+ {type: "integer"}
43
+ elsif rule_str.include?("float?") || rule_str.include?("decimal?")
44
+ {type: "number"}
45
+ elsif rule_str.include?("bool?")
46
+ {type: "boolean"}
47
+ else
48
+ {}
49
+ end
50
+ {type: "array", items: items_type}
51
+ elsif rule_str.include?("hash?")
52
+ {type: "object"}
53
+ elsif rule_str.include?("str?")
54
+ {type: "string"}
55
+ elsif rule_str.include?("int?")
56
+ {type: "integer"}
57
+ elsif rule_str.include?("float?") || rule_str.include?("decimal?")
58
+ {type: "number"}
59
+ elsif rule_str.include?("bool?")
60
+ {type: "boolean"}
61
+ else
62
+ {type: "string"}
63
+ end
64
+
65
+ {
66
+ schema: type_info,
67
+ required: is_required
68
+ }
69
+ end
70
+
71
+ def self.extract_schema_name(schema_obj)
72
+ ObjectSpace.each_object(Class).each do |klass|
73
+ klass.constants.each do |const_name|
74
+ const_value = klass.const_get(const_name)
75
+ return const_name.to_s if const_value == schema_obj
76
+ rescue
77
+ next
78
+ end
79
+ end
80
+
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end