rage-rb 1.10.1 → 1.12.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile +2 -0
- data/README.md +6 -2
- data/lib/rage/cable/adapters/base.rb +16 -0
- data/lib/rage/cable/adapters/redis.rb +127 -0
- data/lib/rage/cable/cable.rb +23 -5
- data/lib/rage/cable/channel.rb +1 -1
- data/lib/rage/cable/protocol/actioncable_v1_json.rb +32 -7
- data/lib/rage/code_loader.rb +8 -0
- data/lib/rage/configuration.rb +50 -0
- data/lib/rage/controller/api.rb +120 -29
- data/lib/rage/cookies.rb +1 -1
- data/lib/rage/ext/setup.rb +6 -4
- data/lib/rage/openapi/builder.rb +85 -0
- data/lib/rage/openapi/collector.rb +44 -0
- data/lib/rage/openapi/converter.rb +141 -0
- data/lib/rage/openapi/index.html.erb +22 -0
- data/lib/rage/openapi/nodes/method.rb +27 -0
- data/lib/rage/openapi/nodes/parent.rb +16 -0
- data/lib/rage/openapi/nodes/root.rb +56 -0
- data/lib/rage/openapi/openapi.rb +146 -0
- data/lib/rage/openapi/parser.rb +204 -0
- data/lib/rage/openapi/parsers/ext/active_record.rb +62 -0
- data/lib/rage/openapi/parsers/ext/alba.rb +285 -0
- data/lib/rage/openapi/parsers/request.rb +18 -0
- data/lib/rage/openapi/parsers/response.rb +19 -0
- data/lib/rage/openapi/parsers/shared_reference.rb +25 -0
- data/lib/rage/openapi/parsers/yaml.rb +66 -0
- data/lib/rage/router/backend.rb +1 -0
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +5 -0
- metadata +19 -2
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "erb"
|
4
|
+
require "yaml"
|
5
|
+
|
6
|
+
if !defined?(Prism)
|
7
|
+
fail <<~ERR
|
8
|
+
|
9
|
+
rage-rb depends on Prism to build OpenAPI specifications. Add the following line to your Gemfile:
|
10
|
+
gem "prism"
|
11
|
+
|
12
|
+
ERR
|
13
|
+
end
|
14
|
+
|
15
|
+
module Rage::OpenAPI
|
16
|
+
# Create a new OpenAPI application.
|
17
|
+
#
|
18
|
+
# @param namespace [String, Module] limit the parser to a specific namespace
|
19
|
+
# @example
|
20
|
+
# map "/publicapi" do
|
21
|
+
# run Rage.openapi.application
|
22
|
+
# end
|
23
|
+
# @example
|
24
|
+
# map "/publicapi/v1" do
|
25
|
+
# run Rage.openapi.application(namespace: "Api::V1")
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# map "/publicapi/v2" do
|
29
|
+
# run Rage.openapi.application(namespace: "Api::V2")
|
30
|
+
# end
|
31
|
+
def self.application(namespace: nil)
|
32
|
+
html_app = ->(env) do
|
33
|
+
__data_cache[[:page, namespace]] ||= begin
|
34
|
+
scheme, host, path = env["rack.url_scheme"], env["HTTP_HOST"], env["SCRIPT_NAME"]
|
35
|
+
spec_url = "#{scheme}://#{host}#{path}/json"
|
36
|
+
page = ERB.new(File.read("#{__dir__}/index.html.erb")).result(binding)
|
37
|
+
|
38
|
+
[200, { "Content-Type" => "text/html; charset=UTF-8" }, [page]]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
json_app = ->(env) do
|
43
|
+
spec = (__data_cache[[:spec, namespace]] ||= build(namespace:).to_json)
|
44
|
+
[200, { "Content-Type" => "application/json" }, [spec]]
|
45
|
+
end
|
46
|
+
|
47
|
+
app = ->(env) do
|
48
|
+
if env["PATH_INFO"] == ""
|
49
|
+
html_app.call(env)
|
50
|
+
elsif env["PATH_INFO"] == "/json"
|
51
|
+
json_app.call(env)
|
52
|
+
else
|
53
|
+
[404, {}, ["Not Found"]]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
if Rage.config.middleware.include?(Rage::Reloader)
|
58
|
+
Rage.with_middlewares(app, [Rage::Reloader])
|
59
|
+
elsif defined?(ActionDispatch::Reloader) && Rage.config.middleware.include?(ActionDispatch::Reloader)
|
60
|
+
Rage.with_middlewares(app, [ActionDispatch::Reloader])
|
61
|
+
else
|
62
|
+
app
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Build an OpenAPI specification for the application.
|
67
|
+
# @param namespace [String, Module] limit the parser to a specific namespace
|
68
|
+
# @return [Hash]
|
69
|
+
def self.build(namespace: nil)
|
70
|
+
Builder.new(namespace:).run
|
71
|
+
end
|
72
|
+
|
73
|
+
# @private
|
74
|
+
def self.__shared_components
|
75
|
+
__data_cache[:shared_components] ||= begin
|
76
|
+
components_file = Rage.root.join("config").glob("openapi_components.*")[0]
|
77
|
+
|
78
|
+
if components_file.nil?
|
79
|
+
{}
|
80
|
+
else
|
81
|
+
case components_file.extname
|
82
|
+
when ".yml", ".yaml"
|
83
|
+
YAML.safe_load(components_file.read)
|
84
|
+
when ".json"
|
85
|
+
JSON.parse(components_file.read)
|
86
|
+
else
|
87
|
+
Rage::OpenAPI.__log_warn "unrecognized file extension: #{components_file.relative_path_from(Rage.root)}; expected either .yml or .json"
|
88
|
+
{}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# @private
|
95
|
+
def self.__data_cache
|
96
|
+
@__data_cache ||= {}
|
97
|
+
end
|
98
|
+
|
99
|
+
# @private
|
100
|
+
def self.__reset_data_cache
|
101
|
+
__data_cache.clear
|
102
|
+
end
|
103
|
+
|
104
|
+
# @private
|
105
|
+
def self.__try_parse_collection(str)
|
106
|
+
if str =~ /^Array<([\w\s:\(\)]+)>$/ || str =~ /^\[([\w\s:\(\)]+)\]$/
|
107
|
+
[true, $1]
|
108
|
+
else
|
109
|
+
[false, str]
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# @private
|
114
|
+
def self.__module_parent(klass)
|
115
|
+
klass.name =~ /::[^:]+\z/ ? Object.const_get($`) : Object
|
116
|
+
rescue NameError
|
117
|
+
Object
|
118
|
+
end
|
119
|
+
|
120
|
+
# @private
|
121
|
+
def self.__log_warn(log)
|
122
|
+
puts "WARNING: #{log}"
|
123
|
+
end
|
124
|
+
|
125
|
+
module Nodes
|
126
|
+
end
|
127
|
+
|
128
|
+
module Parsers
|
129
|
+
module Ext
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
require_relative "builder"
|
135
|
+
require_relative "collector"
|
136
|
+
require_relative "parser"
|
137
|
+
require_relative "converter"
|
138
|
+
require_relative "nodes/root"
|
139
|
+
require_relative "nodes/parent"
|
140
|
+
require_relative "nodes/method"
|
141
|
+
require_relative "parsers/ext/alba"
|
142
|
+
require_relative "parsers/ext/active_record"
|
143
|
+
require_relative "parsers/yaml"
|
144
|
+
require_relative "parsers/shared_reference"
|
145
|
+
require_relative "parsers/request"
|
146
|
+
require_relative "parsers/response"
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::OpenAPI::Parser
|
4
|
+
# @param node [Rage::OpenAPI::Nodes::Parent]
|
5
|
+
# @param comments [Array<Prism::InlineComment>]
|
6
|
+
def parse_dangling_comments(node, comments)
|
7
|
+
i = 0
|
8
|
+
|
9
|
+
while i < comments.length
|
10
|
+
children = nil
|
11
|
+
expression = comments[i].slice.delete_prefix("#").strip
|
12
|
+
|
13
|
+
if expression =~ /@deprecated\b/
|
14
|
+
if node.deprecated
|
15
|
+
Rage::OpenAPI.__log_warn "duplicate @deprecated tag detected at #{location_msg(comments[i])}"
|
16
|
+
else
|
17
|
+
node.deprecated = true
|
18
|
+
end
|
19
|
+
children = find_children(comments[i + 1..])
|
20
|
+
|
21
|
+
elsif expression =~ /@private\b/
|
22
|
+
if node.private
|
23
|
+
Rage::OpenAPI.__log_warn "duplicate @private tag detected at #{location_msg(comments[i])}"
|
24
|
+
else
|
25
|
+
node.private = true
|
26
|
+
end
|
27
|
+
children = find_children(comments[i + 1..])
|
28
|
+
|
29
|
+
elsif expression =~ /@version\s/
|
30
|
+
if node.root.version
|
31
|
+
Rage::OpenAPI.__log_warn "duplicate @version tag detected at #{location_msg(comments[i])}"
|
32
|
+
else
|
33
|
+
node.root.version = expression[9..]
|
34
|
+
end
|
35
|
+
|
36
|
+
elsif expression =~ /@title\s/
|
37
|
+
if node.root.title
|
38
|
+
Rage::OpenAPI.__log_warn "duplicate @title tag detected at #{location_msg(comments[i])}"
|
39
|
+
else
|
40
|
+
node.root.title = expression[7..]
|
41
|
+
end
|
42
|
+
|
43
|
+
elsif expression =~ /@response\s/
|
44
|
+
parse_response_tag(expression, node, comments[i])
|
45
|
+
|
46
|
+
elsif expression =~ /@auth\s/
|
47
|
+
method, name, tail_name = expression[6..].split(" ", 3)
|
48
|
+
children = find_children(comments[i + 1..])
|
49
|
+
|
50
|
+
if tail_name
|
51
|
+
Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
|
52
|
+
end
|
53
|
+
|
54
|
+
auth_entry = {
|
55
|
+
method:,
|
56
|
+
name: name || method,
|
57
|
+
definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
|
58
|
+
}
|
59
|
+
|
60
|
+
if !node.controller.__before_action_exists?(method.to_sym)
|
61
|
+
Rage::OpenAPI.__log_warn "referenced before action `#{method}` is not defined in #{node.controller} at #{location_msg(comments[i])}; ensure a corresponding `before_action` call exists"
|
62
|
+
elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
|
63
|
+
Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
|
64
|
+
else
|
65
|
+
node.auth << auth_entry
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
if children&.any?
|
70
|
+
i += children.length + 1
|
71
|
+
else
|
72
|
+
i += 1
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# @param node [Rage::OpenAPI::Nodes::Method]
|
78
|
+
# @param comments [Array<Prism::InlineComment>]
|
79
|
+
def parse_method_comments(node, comments)
|
80
|
+
i = 0
|
81
|
+
|
82
|
+
while i < comments.length
|
83
|
+
children = nil
|
84
|
+
expression = comments[i].slice.delete_prefix("#").strip
|
85
|
+
|
86
|
+
if !expression.start_with?("@")
|
87
|
+
if node.summary
|
88
|
+
Rage::OpenAPI.__log_warn "invalid summary entry detected at #{location_msg(comments[i])}; summary should only be one line"
|
89
|
+
else
|
90
|
+
node.summary = expression
|
91
|
+
end
|
92
|
+
|
93
|
+
elsif expression =~ /@deprecated\b/
|
94
|
+
if node.parents.any?(&:deprecated)
|
95
|
+
Rage::OpenAPI.__log_warn "duplicate `@deprecated` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
|
96
|
+
else
|
97
|
+
node.deprecated = true
|
98
|
+
end
|
99
|
+
children = find_children(comments[i + 1..])
|
100
|
+
|
101
|
+
elsif expression =~ /@private\b/
|
102
|
+
if node.parents.any?(&:private)
|
103
|
+
Rage::OpenAPI.__log_warn "duplicate `@private` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
|
104
|
+
else
|
105
|
+
node.private = true
|
106
|
+
end
|
107
|
+
children = find_children(comments[i + 1..])
|
108
|
+
|
109
|
+
elsif expression =~ /@description\s/
|
110
|
+
children = find_children(comments[i + 1..])
|
111
|
+
node.description = [expression[13..]] + children
|
112
|
+
|
113
|
+
elsif expression =~ /@response\s/
|
114
|
+
parse_response_tag(expression, node, comments[i])
|
115
|
+
|
116
|
+
elsif expression =~ /@request\s/
|
117
|
+
request = expression[9..]
|
118
|
+
if node.request
|
119
|
+
Rage::OpenAPI.__log_warn "duplicate `@request` tag detected at #{location_msg(comments[i])}"
|
120
|
+
else
|
121
|
+
parsed = Rage::OpenAPI::Parsers::Request.parse(
|
122
|
+
request,
|
123
|
+
namespace: Rage::OpenAPI.__module_parent(node.controller)
|
124
|
+
)
|
125
|
+
|
126
|
+
if parsed
|
127
|
+
node.request = parsed
|
128
|
+
else
|
129
|
+
Rage::OpenAPI.__log_warn "unrecognized `@request` tag detected at #{location_msg(comments[i])}"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
elsif expression =~ /@internal\b/
|
134
|
+
# no-op
|
135
|
+
children = find_children(comments[i + 1..])
|
136
|
+
|
137
|
+
else
|
138
|
+
Rage::OpenAPI.__log_warn "unrecognized `#{expression.split(" ")[0]}` tag detected at #{location_msg(comments[i])}"
|
139
|
+
end
|
140
|
+
|
141
|
+
if children&.any?
|
142
|
+
i += children.length + 1
|
143
|
+
else
|
144
|
+
i += 1
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
def find_children(comments)
|
152
|
+
children = []
|
153
|
+
|
154
|
+
comments.each do |comment|
|
155
|
+
expression = comment.slice.sub(/^#\s/, "")
|
156
|
+
|
157
|
+
if expression.start_with?(/\s{2}/)
|
158
|
+
children << expression.strip
|
159
|
+
elsif expression.start_with?("@")
|
160
|
+
break
|
161
|
+
else
|
162
|
+
Rage::OpenAPI.__log_warn "unrecognized expression detected at #{location_msg(comment)}; use two spaces to mark multi-line expressions"
|
163
|
+
break
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
children
|
168
|
+
end
|
169
|
+
|
170
|
+
def location_msg(comment)
|
171
|
+
location = comment.location
|
172
|
+
relative_path = Pathname.new(location.__source_path).relative_path_from(Rage.root)
|
173
|
+
|
174
|
+
"#{relative_path}:#{location.start_line}"
|
175
|
+
end
|
176
|
+
|
177
|
+
def parse_response_tag(expression, node, comment)
|
178
|
+
response = expression[10..].strip
|
179
|
+
status, response_data = if response =~ /^\d{3}$/
|
180
|
+
[response, nil]
|
181
|
+
elsif response =~ /^\d{3}/
|
182
|
+
response.split(" ", 2)
|
183
|
+
else
|
184
|
+
["200", response]
|
185
|
+
end
|
186
|
+
|
187
|
+
if node.responses.has_key?(status)
|
188
|
+
Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comment)}"
|
189
|
+
elsif response_data.nil?
|
190
|
+
node.responses[status] = nil
|
191
|
+
else
|
192
|
+
parsed = Rage::OpenAPI::Parsers::Response.parse(
|
193
|
+
response_data,
|
194
|
+
namespace: Rage::OpenAPI.__module_parent(node.controller)
|
195
|
+
)
|
196
|
+
|
197
|
+
if parsed
|
198
|
+
node.responses[status] = parsed
|
199
|
+
else
|
200
|
+
Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comment)}"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::OpenAPI::Parsers::Ext::ActiveRecord
|
4
|
+
BLACKLISTED_ATTRIBUTES = %w(id created_at updated_at)
|
5
|
+
|
6
|
+
def initialize(namespace: Object, **)
|
7
|
+
@namespace = namespace
|
8
|
+
end
|
9
|
+
|
10
|
+
def known_definition?(str)
|
11
|
+
_, str = Rage::OpenAPI.__try_parse_collection(str)
|
12
|
+
defined?(ActiveRecord::Base) && @namespace.const_get(str).ancestors.include?(ActiveRecord::Base)
|
13
|
+
rescue NameError
|
14
|
+
false
|
15
|
+
end
|
16
|
+
|
17
|
+
def parse(klass_str)
|
18
|
+
is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
|
19
|
+
klass = @namespace.const_get(klass_str)
|
20
|
+
|
21
|
+
schema = {}
|
22
|
+
|
23
|
+
klass.attribute_types.each do |attr_name, attr_type|
|
24
|
+
next if BLACKLISTED_ATTRIBUTES.include?(attr_name) ||
|
25
|
+
attr_name.end_with?("_id") ||
|
26
|
+
attr_name == klass.inheritance_column ||
|
27
|
+
klass.defined_enums.include?(attr_name)
|
28
|
+
|
29
|
+
schema[attr_name] = case attr_type.type
|
30
|
+
when :integer
|
31
|
+
{ "type" => "integer" }
|
32
|
+
when :boolean
|
33
|
+
{ "type" => "boolean" }
|
34
|
+
when :binary
|
35
|
+
{ "type" => "string", "format" => "binary" }
|
36
|
+
when :date
|
37
|
+
{ "type" => "string", "format" => "date" }
|
38
|
+
when :datetime, :time
|
39
|
+
{ "type" => "string", "format" => "date-time" }
|
40
|
+
when :float
|
41
|
+
{ "type" => "number", "format" => "float" }
|
42
|
+
when :decimal
|
43
|
+
{ "type" => "number" }
|
44
|
+
when :json
|
45
|
+
{ "type" => "object" }
|
46
|
+
else
|
47
|
+
{ "type" => "string" }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
klass.defined_enums.each do |attr_name, mapping|
|
52
|
+
schema[attr_name] = { "type" => "string", "enum" => mapping.keys }
|
53
|
+
end
|
54
|
+
|
55
|
+
result = { "type" => "object" }
|
56
|
+
result["properties"] = schema if schema.any?
|
57
|
+
|
58
|
+
result = { "type" => "array", "items" => result } if is_collection
|
59
|
+
|
60
|
+
result
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,285 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::OpenAPI::Parsers::Ext::Alba
|
4
|
+
attr_reader :namespace
|
5
|
+
|
6
|
+
def initialize(namespace: Object, **)
|
7
|
+
@namespace = namespace
|
8
|
+
end
|
9
|
+
|
10
|
+
def known_definition?(str)
|
11
|
+
_, str = Rage::OpenAPI.__try_parse_collection(str)
|
12
|
+
defined?(Alba::Resource) && @namespace.const_get(str).ancestors.include?(Alba::Resource)
|
13
|
+
rescue NameError
|
14
|
+
false
|
15
|
+
end
|
16
|
+
|
17
|
+
def parse(klass_str)
|
18
|
+
__parse(klass_str).build_schema
|
19
|
+
end
|
20
|
+
|
21
|
+
def __parse_nested(klass_str)
|
22
|
+
__parse(klass_str).tap { |visitor|
|
23
|
+
visitor.root_key = visitor.root_key_for_collection = visitor.key_transformer = nil
|
24
|
+
}.build_schema
|
25
|
+
end
|
26
|
+
|
27
|
+
def __parse(klass_str)
|
28
|
+
is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
|
29
|
+
|
30
|
+
klass = @namespace.const_get(klass_str)
|
31
|
+
source_path, _ = Object.const_source_location(klass.name)
|
32
|
+
ast = Prism.parse_file(source_path)
|
33
|
+
|
34
|
+
visitor = Visitor.new(self, is_collection)
|
35
|
+
ast.value.accept(visitor)
|
36
|
+
|
37
|
+
visitor
|
38
|
+
end
|
39
|
+
|
40
|
+
class VisitorContext
|
41
|
+
attr_accessor :symbols, :hashes, :keywords, :consts, :nil
|
42
|
+
|
43
|
+
def initialize
|
44
|
+
@symbols = []
|
45
|
+
@hashes = []
|
46
|
+
@keywords = {}
|
47
|
+
@consts = []
|
48
|
+
@nil = false
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class Visitor < Prism::Visitor
|
53
|
+
attr_accessor :schema, :root_key, :root_key_for_collection, :key_transformer, :collection_key, :meta
|
54
|
+
|
55
|
+
def initialize(parser, is_collection)
|
56
|
+
@parser = parser
|
57
|
+
@is_collection = is_collection
|
58
|
+
|
59
|
+
@schema = {}
|
60
|
+
@segment = @schema
|
61
|
+
@context = nil
|
62
|
+
@prev_contexts = []
|
63
|
+
|
64
|
+
@self_name = nil
|
65
|
+
@root_key = nil
|
66
|
+
@root_key_for_collection = nil
|
67
|
+
@key_transformer = nil
|
68
|
+
@collection_key = false
|
69
|
+
@meta = {}
|
70
|
+
end
|
71
|
+
|
72
|
+
def visit_class_node(node)
|
73
|
+
@self_name ||= node.name.to_s
|
74
|
+
|
75
|
+
if node.name =~ /Resource$|Serializer$/ && node.superclass
|
76
|
+
visitor = @parser.__parse(node.superclass.name)
|
77
|
+
@root_key, @root_key_for_collection = visitor.root_key, visitor.root_key_for_collection
|
78
|
+
@key_transformer, @collection_key, @meta = visitor.key_transformer, visitor.collection_key, visitor.meta
|
79
|
+
@schema.merge!(visitor.schema)
|
80
|
+
end
|
81
|
+
|
82
|
+
super
|
83
|
+
end
|
84
|
+
|
85
|
+
def build_schema
|
86
|
+
result = { "type" => "object" }
|
87
|
+
|
88
|
+
result["properties"] = @schema if @schema.any?
|
89
|
+
|
90
|
+
if @is_collection
|
91
|
+
result = if @collection_key && @root_key_for_collection
|
92
|
+
{ "type" => "object", "properties" => { @root_key_for_collection => { "type" => "object", "additionalProperties" => result }, **@meta } }
|
93
|
+
elsif @collection_key
|
94
|
+
{ "type" => "object", "additionalProperties" => result }
|
95
|
+
elsif @root_key_for_collection
|
96
|
+
{ "type" => "object", "properties" => { @root_key_for_collection => { "type" => "array", "items" => result }, **@meta } }
|
97
|
+
else
|
98
|
+
{ "type" => "array", "items" => result }
|
99
|
+
end
|
100
|
+
elsif @root_key
|
101
|
+
result = { "type" => "object", "properties" => { @root_key => result, **@meta } }
|
102
|
+
end
|
103
|
+
|
104
|
+
result = deep_transform_keys(result) if @key_transformer
|
105
|
+
|
106
|
+
result
|
107
|
+
end
|
108
|
+
|
109
|
+
def visit_call_node(node)
|
110
|
+
case node.name
|
111
|
+
when :root_key
|
112
|
+
context = with_context { visit(node.arguments) }
|
113
|
+
@root_key, @root_key_for_collection = context.symbols
|
114
|
+
|
115
|
+
when :attributes, :attribute
|
116
|
+
context = with_context { visit(node.arguments) }
|
117
|
+
context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } }
|
118
|
+
context.keywords.except("if").each { |key, type| @segment[key] = get_type_definition(type) }
|
119
|
+
|
120
|
+
when :nested, :nested_attribute
|
121
|
+
context = with_context { visit(node.arguments) }
|
122
|
+
with_inner_segment(context.symbols[0]) { visit(node.block) }
|
123
|
+
|
124
|
+
when :meta
|
125
|
+
context = with_context do
|
126
|
+
visit(node.arguments)
|
127
|
+
visit(node.block)
|
128
|
+
end
|
129
|
+
|
130
|
+
key = context.symbols[0] || "meta"
|
131
|
+
unless context.nil
|
132
|
+
@meta = { key => hash_to_openapi_schema(context.hashes[0]) }
|
133
|
+
end
|
134
|
+
|
135
|
+
when :many, :has_many, :one, :has_one, :association
|
136
|
+
is_array = node.name == :many || node.name == :has_many
|
137
|
+
context = with_context { visit(node.arguments) }
|
138
|
+
key = context.keywords["key"] || context.symbols[0]
|
139
|
+
|
140
|
+
if node.block
|
141
|
+
with_inner_segment(key, is_array:) { visit(node.block) }
|
142
|
+
else
|
143
|
+
resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(key.to_s)}Resource")
|
144
|
+
is_valid_resource = @parser.namespace.const_get(resource) rescue false
|
145
|
+
|
146
|
+
@segment[key] = if is_array
|
147
|
+
@parser.__parse_nested(is_valid_resource ? "[#{resource}]" : "[Rage]") # TODO
|
148
|
+
else
|
149
|
+
@parser.__parse_nested(is_valid_resource ? resource : "Rage")
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
when :transform_keys
|
154
|
+
context = with_context { visit(node.arguments) }
|
155
|
+
@key_transformer = get_key_transformer(context.symbols[0])
|
156
|
+
|
157
|
+
when :collection_key
|
158
|
+
@collection_key = true
|
159
|
+
|
160
|
+
when :root_key!
|
161
|
+
if (inflector = ::Alba.inflector)
|
162
|
+
suffix = @self_name.end_with?("Resource") ? "Resource" : "Serializer"
|
163
|
+
name = inflector.demodulize(@self_name).delete_suffix(suffix)
|
164
|
+
@root_key = inflector.underscore(name)
|
165
|
+
@root_key_for_collection = inflector.pluralize(@root_key) if @is_collection
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def visit_hash_node(node)
|
171
|
+
parsed_hash = YAML.safe_load(node.slice) rescue nil
|
172
|
+
@context.hashes << parsed_hash if parsed_hash
|
173
|
+
end
|
174
|
+
|
175
|
+
def visit_assoc_node(node)
|
176
|
+
value = case node.value
|
177
|
+
when Prism::StringNode
|
178
|
+
node.value.content
|
179
|
+
when Prism::ArrayNode
|
180
|
+
context = with_context { visit(node.value) }
|
181
|
+
context.symbols[0] || context.consts[0]
|
182
|
+
else
|
183
|
+
node.value.slice
|
184
|
+
end
|
185
|
+
|
186
|
+
@context.keywords[node.key.value] = value
|
187
|
+
end
|
188
|
+
|
189
|
+
def visit_constant_read_node(node)
|
190
|
+
return unless @context
|
191
|
+
@context.consts << node.name.to_s
|
192
|
+
end
|
193
|
+
|
194
|
+
def visit_symbol_node(node)
|
195
|
+
@context.symbols << node.value
|
196
|
+
end
|
197
|
+
|
198
|
+
def visit_nil_node(node)
|
199
|
+
@context.nil = true
|
200
|
+
end
|
201
|
+
|
202
|
+
private
|
203
|
+
|
204
|
+
def with_inner_segment(key, is_array: false)
|
205
|
+
prev_segment = @segment
|
206
|
+
|
207
|
+
properties = {}
|
208
|
+
if is_array
|
209
|
+
@segment[key] = { "type" => "array", "items" => { "type" => "object", "properties" => properties } }
|
210
|
+
else
|
211
|
+
@segment[key] = { "type" => "object", "properties" => properties }
|
212
|
+
end
|
213
|
+
@segment = properties
|
214
|
+
|
215
|
+
yield
|
216
|
+
@segment = prev_segment
|
217
|
+
end
|
218
|
+
|
219
|
+
def with_context
|
220
|
+
@prev_contexts << @context if @context
|
221
|
+
@context = VisitorContext.new
|
222
|
+
yield
|
223
|
+
current_context = @context
|
224
|
+
@context = @prev_contexts.pop
|
225
|
+
current_context
|
226
|
+
end
|
227
|
+
|
228
|
+
def hash_to_openapi_schema(hash)
|
229
|
+
return { "type" => "object" } unless hash
|
230
|
+
|
231
|
+
schema = hash.each_with_object({}) do |(key, value), memo|
|
232
|
+
memo[key.to_s] = if value.is_a?(Hash)
|
233
|
+
hash_to_openapi_schema(value)
|
234
|
+
elsif value.is_a?(Array)
|
235
|
+
{ "type" => "array", "items" => { "type" => "string" } }
|
236
|
+
else
|
237
|
+
{ "type" => "string" }
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
{ "type" => "object", "properties" => schema }
|
242
|
+
end
|
243
|
+
|
244
|
+
def deep_transform_keys(schema)
|
245
|
+
schema.each_with_object({}) do |(key, value), memo|
|
246
|
+
transformed_key = %w(type properties items additionalProperties).include?(key) ? key : @key_transformer.call(key)
|
247
|
+
memo[transformed_key] = value.is_a?(Hash) ? deep_transform_keys(value) : value
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def get_key_transformer(transformer_id)
|
252
|
+
return nil unless ::Alba.inflector
|
253
|
+
|
254
|
+
case transformer_id
|
255
|
+
when "camel"
|
256
|
+
->(key) { ::Alba.inflector.camelize(key) }
|
257
|
+
when "lower_camel"
|
258
|
+
->(key) { ::Alba.inflector.camelize_lower(key) }
|
259
|
+
when "dash"
|
260
|
+
->(key) { ::Alba.inflector.dasherize(key) }
|
261
|
+
when "snake"
|
262
|
+
->(key) { ::Alba.inflector.underscore(key) }
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
def get_type_definition(type_id)
|
267
|
+
case type_id
|
268
|
+
when "Integer"
|
269
|
+
{ "type" => "integer" }
|
270
|
+
when "Boolean", ":Boolean"
|
271
|
+
{ "type" => "boolean" }
|
272
|
+
when "Numeric"
|
273
|
+
{ "type" => "number" }
|
274
|
+
when "Float"
|
275
|
+
{ "type" => "number", "format" => "float" }
|
276
|
+
when "Date"
|
277
|
+
{ "type" => "string", "format" => "date" }
|
278
|
+
when "DateTime", "Time"
|
279
|
+
{ "type" => "string", "format" => "date-time" }
|
280
|
+
else
|
281
|
+
{ "type" => "string" }
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|