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.
@@ -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