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,179 @@
1
+ require_relative "schema_converter"
2
+
3
+ module FunApi
4
+ module OpenAPI
5
+ class SpecGenerator
6
+ def initialize(routes, info:)
7
+ @routes = routes
8
+ @info = info
9
+ @schemas = {}
10
+ @schema_counter = 0
11
+ end
12
+
13
+ def generate
14
+ {
15
+ openapi: "3.0.3",
16
+ info: build_info,
17
+ paths: build_paths,
18
+ components: build_components
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def build_info
25
+ {
26
+ title: @info[:title],
27
+ version: @info[:version],
28
+ description: @info[:description]
29
+ }
30
+ end
31
+
32
+ def build_paths
33
+ paths = {}
34
+
35
+ @routes.each do |route|
36
+ next if route.metadata[:internal]
37
+
38
+ path_template = convert_path_template(route.metadata[:path_template])
39
+ paths[path_template] ||= {}
40
+
41
+ operation = build_operation(route)
42
+ paths[path_template][route.verb.downcase] = operation
43
+ end
44
+
45
+ paths
46
+ end
47
+
48
+ def build_operation(route)
49
+ operation = {}
50
+
51
+ parameters = []
52
+ parameters.concat(build_path_parameters(route))
53
+ parameters.concat(build_query_parameters(route))
54
+
55
+ operation[:parameters] = parameters unless parameters.empty?
56
+ operation[:requestBody] = build_request_body(route) if route.metadata[:body_schema]
57
+ operation[:responses] = build_responses(route)
58
+
59
+ operation
60
+ end
61
+
62
+ def build_path_parameters(route)
63
+ route.keys.map do |key|
64
+ {
65
+ name: key,
66
+ in: "path",
67
+ required: true,
68
+ schema: {type: "string"}
69
+ }
70
+ end
71
+ end
72
+
73
+ def build_query_parameters(route)
74
+ query_schema = route.metadata[:query_schema]
75
+ return [] unless query_schema
76
+
77
+ schema = unwrap_array_schema(query_schema)
78
+ json_schema = SchemaConverter.to_json_schema(schema)
79
+ return [] unless json_schema && json_schema[:properties]
80
+
81
+ required_fields = json_schema[:required] || []
82
+
83
+ json_schema[:properties].map do |name, prop_schema|
84
+ {
85
+ name: name.to_s,
86
+ in: "query",
87
+ required: required_fields.include?(name.to_s),
88
+ schema: prop_schema
89
+ }
90
+ end
91
+ end
92
+
93
+ def build_request_body(route)
94
+ body_schema = route.metadata[:body_schema]
95
+ return nil unless body_schema
96
+
97
+ schema_ref = register_schema(body_schema, route.verb, route.metadata[:path_template])
98
+
99
+ {
100
+ required: true,
101
+ content: {
102
+ "application/json": {
103
+ schema: schema_ref
104
+ }
105
+ }
106
+ }
107
+ end
108
+
109
+ def build_responses(route)
110
+ response_schema = route.metadata[:response_schema]
111
+
112
+ if response_schema
113
+ schema_ref = register_schema(response_schema, "#{route.verb}Response", route.metadata[:path_template])
114
+
115
+ {
116
+ "200": {
117
+ description: "Successful response",
118
+ content: {
119
+ "application/json": {
120
+ schema: schema_ref
121
+ }
122
+ }
123
+ }
124
+ }
125
+ else
126
+ {
127
+ "200": {
128
+ description: "Successful response"
129
+ }
130
+ }
131
+ end
132
+ end
133
+
134
+ def register_schema(schema, verb, path)
135
+ schema_obj = unwrap_array_schema(schema)
136
+ is_array = schema.is_a?(Array)
137
+
138
+ schema_name = SchemaConverter.extract_schema_name(schema_obj)
139
+
140
+ unless schema_name
141
+ @schema_counter += 1
142
+ method_name = path.split("/").reject(&:empty?).map do |s|
143
+ s.start_with?(":") ? s[1..].capitalize : s.capitalize
144
+ end.join
145
+ schema_name = "#{method_name}#{verb.capitalize}Schema#{@schema_counter}"
146
+ end
147
+
148
+ @schemas[schema_name] = SchemaConverter.to_json_schema(schema_obj)
149
+
150
+ if is_array
151
+ {
152
+ type: "array",
153
+ items: {"$ref": "#/components/schemas/#{schema_name}"}
154
+ }
155
+ else
156
+ {"$ref": "#/components/schemas/#{schema_name}"}
157
+ end
158
+ end
159
+
160
+ def build_components
161
+ {
162
+ schemas: @schemas
163
+ }
164
+ end
165
+
166
+ def convert_path_template(path)
167
+ path.gsub(/:(\w+)/, '{\1}')
168
+ end
169
+
170
+ def unwrap_array_schema(schema)
171
+ if schema.is_a?(Array) && schema.length == 1
172
+ schema.first
173
+ else
174
+ schema
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunApi
4
+ class Router
5
+ Route = Struct.new(:verb, :pattern, :keys, :handler, :metadata)
6
+
7
+ def initialize
8
+ @routes = []
9
+ end
10
+
11
+ attr_reader :routes
12
+
13
+ def add(verb, path, metadata: {}, &handler)
14
+ keys = []
15
+
16
+ regex = if path == "/"
17
+ "/"
18
+ else
19
+ path.split("/").map do |seg|
20
+ if seg.start_with?(":")
21
+ keys << seg.delete_prefix(":")
22
+ "([^/]+)"
23
+ else
24
+ Regexp.escape(seg)
25
+ end
26
+ end.join("/")
27
+ end
28
+
29
+ route_metadata = metadata.merge(path_template: path)
30
+ @routes << Route.new(verb.upcase, /\A#{regex}\z/, keys, handler, route_metadata)
31
+ end
32
+
33
+ def call(env)
34
+ req = Rack::Request.new(env)
35
+ route = @routes.find { |r| r.verb == req.request_method && r.pattern =~ req.path_info }
36
+ return [404, {"content-type" => "application/json"}, [JSON.dump(error: "Not found")]] unless route
37
+
38
+ match = route.pattern.match(req.path_info)
39
+ params = route.keys.zip(match.captures).to_h
40
+ route.handler.call(req, params)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ require "dry-schema"
2
+
3
+ module FunApi
4
+ class Schema
5
+ def self.define(&block)
6
+ Dry::Schema.Params(&block)
7
+ end
8
+
9
+ def self.validate(schema, data, location: "body")
10
+ return data unless schema
11
+
12
+ if schema.is_a?(Array) && schema.length == 1
13
+ item_schema = schema.first
14
+ data_array = data.is_a?(Array) ? data : []
15
+
16
+ results = data_array.map do |item|
17
+ result = item_schema.call(item || {})
18
+ raise ValidationError.new(errors: result.errors) unless result.success?
19
+
20
+ result.to_h
21
+ end
22
+
23
+ return results
24
+ end
25
+
26
+ result = schema.call(data || {})
27
+ raise ValidationError.new(errors: result.errors) unless result.success?
28
+
29
+ result.to_h
30
+ end
31
+
32
+ def self.validate_response(schema, data)
33
+ return data unless schema
34
+
35
+ if schema.is_a?(Array) && schema.length == 1
36
+ item_schema = schema.first
37
+ data_array = data.is_a?(Array) ? data : []
38
+
39
+ return data_array.map do |item|
40
+ result = item_schema.call(item)
41
+
42
+ unless result.success?
43
+ raise HTTPException.new(
44
+ status_code: 500,
45
+ detail: "Response validation failed: #{result.errors.to_h}"
46
+ )
47
+ end
48
+
49
+ result.to_h
50
+ end
51
+ end
52
+
53
+ result = schema.call(data)
54
+
55
+ unless result.success?
56
+ raise HTTPException.new(
57
+ status_code: 500,
58
+ detail: "Response validation failed: #{result.errors.to_h}"
59
+ )
60
+ end
61
+
62
+ result.to_h
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "falcon"
4
+ require "protocol/rack"
5
+
6
+ module FunApi
7
+ module Server
8
+ # Falcon server adapter - uses protocol-rack for proper Rack integration
9
+ class Falcon
10
+ def self.start(app, host: "0.0.0.0", port: 3000)
11
+ Async do |task|
12
+ falcon_app = Protocol::Rack::Adapter.new(app)
13
+ endpoint = ::Async::HTTP::Endpoint.parse("http://#{host}:#{port}").with(protocols: Async::HTTP::Protocol::HTTP2)
14
+
15
+ server = ::Falcon::Server.new(falcon_app, endpoint)
16
+
17
+ app.run_startup_hooks if app.respond_to?(:run_startup_hooks)
18
+
19
+ puts "Falcon listening on #{host}:#{port}"
20
+ puts "Try: curl http://#{host}:#{port}/hello"
21
+ puts "Press Ctrl+C to stop"
22
+
23
+ shutdown = -> {
24
+ puts "\nShutting down..."
25
+ app.run_shutdown_hooks if app.respond_to?(:run_shutdown_hooks)
26
+ task.stop
27
+ exit
28
+ }
29
+
30
+ trap(:INT) { shutdown.call }
31
+ trap(:TERM) { shutdown.call }
32
+
33
+ server.run
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunApi
4
+ class TemplateResponse
5
+ attr_reader :body, :status, :headers
6
+
7
+ def initialize(body, status: 200, headers: {})
8
+ @body = body
9
+ @status = status
10
+ @headers = {"content-type" => "text/html; charset=utf-8"}.merge(headers)
11
+ end
12
+
13
+ def to_response
14
+ [status, headers, [body]]
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "pathname"
5
+ require_relative "template_response"
6
+ require_relative "exceptions"
7
+
8
+ module FunApi
9
+ class Templates
10
+ def initialize(directory:, layout: nil)
11
+ @directory = Pathname.new(directory)
12
+ @layout = layout
13
+ @cache = {}
14
+ end
15
+
16
+ def with_layout(layout)
17
+ ScopedTemplates.new(self, layout)
18
+ end
19
+
20
+ def render(name, layout: nil, **context)
21
+ content = render_template(name, **context)
22
+
23
+ layout_to_use = determine_layout(layout)
24
+ if layout_to_use
25
+ render_with_layout(layout_to_use, content, **context)
26
+ else
27
+ content
28
+ end
29
+ end
30
+
31
+ def response(name, status: 200, headers: {}, layout: nil, **context)
32
+ html = render(name, layout: layout, **context)
33
+ TemplateResponse.new(html, status: status, headers: headers)
34
+ end
35
+
36
+ def render_partial(name, **context)
37
+ render_template(name, **context)
38
+ end
39
+
40
+ private
41
+
42
+ def determine_layout(layout_override)
43
+ return nil if layout_override == false
44
+ return layout_override if layout_override
45
+
46
+ @layout
47
+ end
48
+
49
+ def render_with_layout(layout_name, content, **context)
50
+ template = load_template(layout_name)
51
+ template_context = TemplateContext.new(self, context, content: content)
52
+ template.result(template_context.get_binding)
53
+ end
54
+
55
+ def render_template(name, **context)
56
+ template = load_template(name)
57
+ template_context = TemplateContext.new(self, context)
58
+ template.result(template_context.get_binding)
59
+ end
60
+
61
+ def load_template(name)
62
+ path = @directory.join(name)
63
+ raise TemplateNotFoundError.new(name) unless path.exist?
64
+
65
+ @cache[name] ||= ERB.new(path.read, trim_mode: "-")
66
+ end
67
+ end
68
+
69
+ class TemplateContext
70
+ def initialize(templates, context, content: nil)
71
+ @templates = templates
72
+ @content = content
73
+ context.each do |key, value|
74
+ define_singleton_method(key) { value }
75
+ end
76
+ end
77
+
78
+ def render_partial(name, **context)
79
+ @templates.render_partial(name, **context)
80
+ end
81
+
82
+ def yield_content
83
+ @content
84
+ end
85
+
86
+ def get_binding
87
+ binding
88
+ end
89
+ end
90
+
91
+ class ScopedTemplates
92
+ def initialize(templates, layout)
93
+ @templates = templates
94
+ @layout = layout
95
+ end
96
+
97
+ def render(name, layout: nil, **context)
98
+ effective_layout = layout.nil? ? @layout : layout
99
+ @templates.render(name, layout: effective_layout, **context)
100
+ end
101
+
102
+ def response(name, status: 200, headers: {}, layout: nil, **context)
103
+ effective_layout = layout.nil? ? @layout : layout
104
+ @templates.response(name, status: status, headers: headers, layout: effective_layout, **context)
105
+ end
106
+
107
+ def render_partial(name, **context)
108
+ @templates.render_partial(name, **context)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunApi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/funapi.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "funapi/version"
4
+ require_relative "funapi/exceptions"
5
+ require_relative "funapi/schema"
6
+ require_relative "funapi/depends"
7
+ require_relative "funapi/dependency_wrapper"
8
+ require_relative "funapi/async"
9
+ require_relative "funapi/router"
10
+ require_relative "funapi/application"
11
+
12
+ module FunApi
13
+ class Error < StandardError; end
14
+ end