rage-rb 1.10.1 → 1.11.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/Gemfile +1 -0
- data/README.md +5 -0
- data/lib/rage/code_loader.rb +8 -0
- data/lib/rage/configuration.rb +23 -0
- data/lib/rage/controller/api.rb +27 -12
- data/lib/rage/ext/setup.rb +6 -4
- data/lib/rage/openapi/builder.rb +84 -0
- data/lib/rage/openapi/collector.rb +43 -0
- data/lib/rage/openapi/converter.rb +138 -0
- data/lib/rage/openapi/index.html.erb +22 -0
- data/lib/rage/openapi/nodes/method.rb +24 -0
- data/lib/rage/openapi/nodes/parent.rb +13 -0
- data/lib/rage/openapi/nodes/root.rb +49 -0
- data/lib/rage/openapi/openapi.rb +146 -0
- data/lib/rage/openapi/parser.rb +193 -0
- data/lib/rage/openapi/parsers/ext/active_record.rb +62 -0
- data/lib/rage/openapi/parsers/ext/alba.rb +281 -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 +17 -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,193 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::OpenAPI::Parser
|
4
|
+
def parse_dangling_comments(node, comments)
|
5
|
+
i = 0
|
6
|
+
|
7
|
+
while i < comments.length
|
8
|
+
children = nil
|
9
|
+
expression = comments[i].slice.delete_prefix("#").strip
|
10
|
+
|
11
|
+
if expression =~ /@deprecated\b/
|
12
|
+
if node.deprecated
|
13
|
+
Rage::OpenAPI.__log_warn "duplicate @deprecated tag detected at #{location_msg(comments[i])}"
|
14
|
+
else
|
15
|
+
node.deprecated = true
|
16
|
+
end
|
17
|
+
children = find_children(comments[i + 1..])
|
18
|
+
|
19
|
+
elsif expression =~ /@private\b/
|
20
|
+
if node.private
|
21
|
+
Rage::OpenAPI.__log_warn "duplicate @private tag detected at #{location_msg(comments[i])}"
|
22
|
+
else
|
23
|
+
node.private = true
|
24
|
+
end
|
25
|
+
children = find_children(comments[i + 1..])
|
26
|
+
|
27
|
+
elsif expression =~ /@version\s/
|
28
|
+
if node.root.version
|
29
|
+
Rage::OpenAPI.__log_warn "duplicate @version tag detected at #{location_msg(comments[i])}"
|
30
|
+
else
|
31
|
+
node.root.version = expression[9..]
|
32
|
+
end
|
33
|
+
|
34
|
+
elsif expression =~ /@title\s/
|
35
|
+
if node.root.title
|
36
|
+
Rage::OpenAPI.__log_warn "duplicate @title tag detected at #{location_msg(comments[i])}"
|
37
|
+
else
|
38
|
+
node.root.title = expression[7..]
|
39
|
+
end
|
40
|
+
|
41
|
+
elsif expression =~ /@auth\s/
|
42
|
+
method, name, tail_name = expression[6..].split(" ", 3)
|
43
|
+
children = find_children(comments[i + 1..])
|
44
|
+
|
45
|
+
if tail_name
|
46
|
+
Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
|
47
|
+
end
|
48
|
+
|
49
|
+
auth_entry = {
|
50
|
+
method:,
|
51
|
+
name: name || method,
|
52
|
+
definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
|
53
|
+
}
|
54
|
+
|
55
|
+
if !node.controller.__before_action_exists?(method.to_sym)
|
56
|
+
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"
|
57
|
+
elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
|
58
|
+
Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
|
59
|
+
else
|
60
|
+
node.auth << auth_entry
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
if children&.any?
|
65
|
+
i += children.length + 1
|
66
|
+
else
|
67
|
+
i += 1
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def parse_method_comments(node, comments)
|
73
|
+
i = 0
|
74
|
+
|
75
|
+
while i < comments.length
|
76
|
+
children = nil
|
77
|
+
expression = comments[i].slice.delete_prefix("#").strip
|
78
|
+
|
79
|
+
if !expression.start_with?("@")
|
80
|
+
if node.summary
|
81
|
+
Rage::OpenAPI.__log_warn "invalid summary entry detected at #{location_msg(comments[i])}; summary should only be one line"
|
82
|
+
else
|
83
|
+
node.summary = expression
|
84
|
+
end
|
85
|
+
|
86
|
+
elsif expression =~ /@deprecated\b/
|
87
|
+
if node.parents.any?(&:deprecated)
|
88
|
+
Rage::OpenAPI.__log_warn "duplicate `@deprecated` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
|
89
|
+
else
|
90
|
+
node.deprecated = true
|
91
|
+
end
|
92
|
+
children = find_children(comments[i + 1..])
|
93
|
+
|
94
|
+
elsif expression =~ /@private\b/
|
95
|
+
if node.parents.any?(&:private)
|
96
|
+
Rage::OpenAPI.__log_warn "duplicate `@private` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
|
97
|
+
else
|
98
|
+
node.private = true
|
99
|
+
end
|
100
|
+
children = find_children(comments[i + 1..])
|
101
|
+
|
102
|
+
elsif expression =~ /@description\s/
|
103
|
+
children = find_children(comments[i + 1..])
|
104
|
+
node.description = [expression[13..]] + children
|
105
|
+
|
106
|
+
elsif expression =~ /@response\s/
|
107
|
+
response = expression[10..].strip
|
108
|
+
status, response_data = if response =~ /^\d{3}$/
|
109
|
+
[response, nil]
|
110
|
+
elsif response =~ /^\d{3}/
|
111
|
+
response.split(" ", 2)
|
112
|
+
else
|
113
|
+
["200", response]
|
114
|
+
end
|
115
|
+
|
116
|
+
if node.responses.has_key?(status)
|
117
|
+
Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comments[i])}"
|
118
|
+
elsif response_data.nil?
|
119
|
+
node.responses[status] = nil
|
120
|
+
else
|
121
|
+
parsed = Rage::OpenAPI::Parsers::Response.parse(
|
122
|
+
response_data,
|
123
|
+
namespace: Rage::OpenAPI.__module_parent(node.controller)
|
124
|
+
)
|
125
|
+
|
126
|
+
if parsed
|
127
|
+
node.responses[status] = parsed
|
128
|
+
else
|
129
|
+
Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comments[i])}"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
elsif expression =~ /@request\s/
|
134
|
+
request = expression[9..]
|
135
|
+
if node.request
|
136
|
+
Rage::OpenAPI.__log_warn "duplicate `@request` tag detected at #{location_msg(comments[i])}"
|
137
|
+
else
|
138
|
+
parsed = Rage::OpenAPI::Parsers::Request.parse(
|
139
|
+
request,
|
140
|
+
namespace: Rage::OpenAPI.__module_parent(node.controller)
|
141
|
+
)
|
142
|
+
|
143
|
+
if parsed
|
144
|
+
node.request = parsed
|
145
|
+
else
|
146
|
+
Rage::OpenAPI.__log_warn "unrecognized `@request` tag detected at #{location_msg(comments[i])}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
elsif expression =~ /@internal\b/
|
151
|
+
# no-op
|
152
|
+
children = find_children(comments[i + 1..])
|
153
|
+
|
154
|
+
else
|
155
|
+
Rage::OpenAPI.__log_warn "unrecognized `#{expression.split(" ")[0]}` tag detected at #{location_msg(comments[i])}"
|
156
|
+
end
|
157
|
+
|
158
|
+
if children&.any?
|
159
|
+
i += children.length + 1
|
160
|
+
else
|
161
|
+
i += 1
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
private
|
167
|
+
|
168
|
+
def find_children(comments)
|
169
|
+
children = []
|
170
|
+
|
171
|
+
comments.each do |comment|
|
172
|
+
expression = comment.slice.sub(/^#\s/, "")
|
173
|
+
|
174
|
+
if expression.start_with?(/\s{2}/)
|
175
|
+
children << expression.strip
|
176
|
+
elsif expression.start_with?("@")
|
177
|
+
break
|
178
|
+
else
|
179
|
+
Rage::OpenAPI.__log_warn "unrecognized expression detected at #{location_msg(comment)}; use two spaces to mark multi-line expressions"
|
180
|
+
break
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
children
|
185
|
+
end
|
186
|
+
|
187
|
+
def location_msg(comment)
|
188
|
+
location = comment.location
|
189
|
+
relative_path = Pathname.new(location.__source_path).relative_path_from(Rage.root)
|
190
|
+
|
191
|
+
"#{relative_path}:#{location.start_line}"
|
192
|
+
end
|
193
|
+
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,281 @@
|
|
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
|
+
else
|
277
|
+
{ "type" => "string" }
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::OpenAPI::Parsers::Request
|
4
|
+
AVAILABLE_PARSERS = [
|
5
|
+
Rage::OpenAPI::Parsers::SharedReference,
|
6
|
+
Rage::OpenAPI::Parsers::YAML,
|
7
|
+
Rage::OpenAPI::Parsers::Ext::ActiveRecord
|
8
|
+
]
|
9
|
+
|
10
|
+
def self.parse(request_tag, namespace:)
|
11
|
+
parser = AVAILABLE_PARSERS.find do |parser_class|
|
12
|
+
parser = parser_class.new(namespace:)
|
13
|
+
break parser if parser.known_definition?(request_tag)
|
14
|
+
end
|
15
|
+
|
16
|
+
parser.parse(request_tag) if parser
|
17
|
+
end
|
18
|
+
end
|