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,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,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,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
|