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.
@@ -11,13 +11,10 @@ class RageController::API
11
11
  def __register_action(action)
12
12
  raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
13
13
 
14
- before_actions_chunk = if @__before_actions
15
- filtered_before_actions = @__before_actions.select do |h|
16
- (!h[:only] || h[:only].include?(action)) &&
17
- (!h[:except] || !h[:except].include?(action))
18
- end
14
+ around_actions_total = 0
19
15
 
20
- lines = filtered_before_actions.map do |h|
16
+ before_actions_chunk = if @__before_actions
17
+ lines = __before_actions_for(action).map do |h|
21
18
  condition = if h[:if] && h[:unless]
22
19
  "if #{h[:if]} && !#{h[:unless]}"
23
20
  elsif h[:if]
@@ -26,10 +23,30 @@ class RageController::API
26
23
  "unless #{h[:unless]}"
27
24
  end
28
25
 
29
- <<~RUBY
30
- #{h[:name]} #{condition}
31
- return [@__status, @__headers, @__body] if @__rendered
32
- RUBY
26
+ if h[:around]
27
+ around_actions_total += 1
28
+
29
+ if condition
30
+ <<~RUBY
31
+ __should_apply_around_action = #{condition}
32
+ !@__before_callback_rendered
33
+ end
34
+ #{h[:wrapper]}(__should_apply_around_action) do
35
+ RUBY
36
+ else
37
+ <<~RUBY
38
+ __should_apply_around_action = !@__before_callback_rendered
39
+ #{h[:wrapper]}(__should_apply_around_action) do
40
+ RUBY
41
+ end
42
+ else
43
+ <<~RUBY
44
+ unless @__before_callback_rendered
45
+ #{h[:name]} #{condition}
46
+ @__before_callback_rendered = true if @__rendered
47
+ end
48
+ RUBY
49
+ end
33
50
  end
34
51
 
35
52
  lines.join("\n")
@@ -37,13 +54,10 @@ class RageController::API
37
54
  ""
38
55
  end
39
56
 
40
- after_actions_chunk = if @__after_actions
41
- filtered_after_actions = @__after_actions.select do |h|
42
- (!h[:only] || h[:only].include?(action)) &&
43
- (!h[:except] || !h[:except].include?(action))
44
- end
57
+ around_actions_end_chunk = around_actions_total.times.reduce("") { |memo| memo + "end\n" }
45
58
 
46
- lines = filtered_after_actions.map! do |h|
59
+ after_actions_chunk = if @__after_actions
60
+ lines = __after_actions_for(action).map do |h|
47
61
  condition = if h[:if] && h[:unless]
48
62
  "if #{h[:if]} && !#{h[:unless]}"
49
63
  elsif h[:if]
@@ -106,12 +120,15 @@ class RageController::API
106
120
 
107
121
  #{wrap_parameters_chunk}
108
122
  #{before_actions_chunk}
109
- #{action}
123
+ #{action} unless @__before_callback_rendered
124
+ #{around_actions_end_chunk}
110
125
 
111
126
  #{if !after_actions_chunk.empty?
112
127
  <<~RUBY
113
- @__rendered = true
114
- #{after_actions_chunk}
128
+ unless @__before_callback_rendered
129
+ @__rendered = true
130
+ #{after_actions_chunk}
131
+ end
115
132
  RUBY
116
133
  end}
117
134
 
@@ -161,13 +178,29 @@ class RageController::API
161
178
  end
162
179
 
163
180
  # @private
164
- @@__tmp_name_seed = ("a".."i").to_a.permutation
181
+ @@__dynamic_name_seed = ("a".."i").to_a.permutation
182
+
183
+ # @private
184
+ # define a method based on a block
185
+ def define_dynamic_method(block)
186
+ name = @@__dynamic_name_seed.next.join
187
+ define_method("__rage_dynamic_#{name}", block)
188
+ end
165
189
 
166
190
  # @private
167
- # define temporary method based on a block
168
- def define_tmp_method(block)
169
- name = @@__tmp_name_seed.next.join
170
- define_method("__rage_tmp_#{name}", block)
191
+ # define a method that will call a specified method if a condition is `true` or yield if `false`
192
+ def define_maybe_yield(method_name)
193
+ name = @@__dynamic_name_seed.next.join
194
+
195
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
196
+ def __rage_dynamic_#{name}(condition)
197
+ if condition
198
+ #{method_name} { yield }
199
+ else
200
+ yield
201
+ end
202
+ end
203
+ RUBY
171
204
  end
172
205
 
173
206
  ############
@@ -193,7 +226,7 @@ class RageController::API
193
226
  def rescue_from(*klasses, with: nil, &block)
194
227
  unless with
195
228
  if block_given?
196
- with = define_tmp_method(block)
229
+ with = define_dynamic_method(block)
197
230
  else
198
231
  raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
199
232
  end
@@ -249,6 +282,39 @@ class RageController::API
249
282
  end
250
283
  end
251
284
 
285
+ # Register a new `around_action` hook. Calls with the same `action_name` will overwrite the previous ones.
286
+ #
287
+ # @param action_name [Symbol, nil] the name of the callback to add
288
+ # @param [Hash] opts action options
289
+ # @option opts [Symbol, Array<Symbol>] :only restrict the callback to run only for specific actions
290
+ # @option opts [Symbol, Array<Symbol>] :except restrict the callback to run for all actions except specified
291
+ # @option opts [Symbol, Proc] :if only run the callback if the condition is true
292
+ # @option opts [Symbol, Proc] :unless only run the callback if the condition is false
293
+ # @example
294
+ # around_action :wrap_in_transaction
295
+ #
296
+ # def wrap_in_transaction
297
+ # ActiveRecord::Base.transaction do
298
+ # yield
299
+ # end
300
+ # end
301
+ def around_action(action_name = nil, **opts, &block)
302
+ action = prepare_action_params(action_name, **opts, &block)
303
+ action.merge!(around: true, wrapper: define_maybe_yield(action[:name]))
304
+
305
+ if @__before_actions && @__before_actions.frozen?
306
+ @__before_actions = @__before_actions.dup
307
+ end
308
+
309
+ if @__before_actions.nil?
310
+ @__before_actions = [action]
311
+ elsif (i = @__before_actions.find_index { |a| a[:name] == action_name })
312
+ @__before_actions[i] = action
313
+ else
314
+ @__before_actions << action
315
+ end
316
+ end
317
+
252
318
  # Register a new `after_action` hook. Calls with the same `action_name` will overwrite the previous ones.
253
319
  #
254
320
  # @param action_name [Symbol, nil] the name of the callback to add
@@ -283,7 +349,7 @@ class RageController::API
283
349
  # @example
284
350
  # skip_before_action :find_photo, only: :create
285
351
  def skip_before_action(action_name, only: nil, except: nil)
286
- i = @__before_actions&.find_index { |a| a[:name] == action_name }
352
+ i = @__before_actions&.find_index { |a| a[:name] == action_name && !a[:around] }
287
353
  raise ArgumentError, "The following action was specified to be skipped but couldn't be found: #{self}##{action_name}" unless i
288
354
 
289
355
  @__before_actions = @__before_actions.dup if @__before_actions.frozen?
@@ -319,12 +385,37 @@ class RageController::API
319
385
  @__wrap_parameters_options = { include:, exclude: }
320
386
  end
321
387
 
388
+ # @private
389
+ def __before_action_exists?(name)
390
+ @__before_actions.any? { |h| h[:name] == name && !h[:around] }
391
+ end
392
+
393
+ # @private
394
+ def __before_actions_for(action_name)
395
+ return [] unless @__before_actions
396
+
397
+ @__before_actions.select do |h|
398
+ (!h[:only] || h[:only].include?(action_name)) &&
399
+ (!h[:except] || !h[:except].include?(action_name))
400
+ end
401
+ end
402
+
403
+ # @private
404
+ def __after_actions_for(action_name)
405
+ return [] unless @__after_actions
406
+
407
+ @__after_actions.select do |h|
408
+ (!h[:only] || h[:only].include?(action_name)) &&
409
+ (!h[:except] || !h[:except].include?(action_name))
410
+ end
411
+ end
412
+
322
413
  private
323
414
 
324
415
  # used by `before_action` and `after_action`
325
416
  def prepare_action_params(action_name = nil, **opts, &block)
326
417
  if block_given?
327
- action_name = define_tmp_method(block)
418
+ action_name = define_dynamic_method(block)
328
419
  elsif action_name.nil?
329
420
  raise ArgumentError, "No handler provided. Pass the `action_name` parameter or provide a block."
330
421
  end
@@ -339,8 +430,8 @@ class RageController::API
339
430
  unless: _unless
340
431
  }
341
432
 
342
- action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
343
- action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
433
+ action[:if] = define_dynamic_method(action[:if]) if action[:if].is_a?(Proc)
434
+ action[:unless] = define_dynamic_method(action[:unless]) if action[:unless].is_a?(Proc)
344
435
 
345
436
  action
346
437
  end
data/lib/rage/cookies.rb CHANGED
@@ -69,7 +69,7 @@ class Rage::Cookies
69
69
  # @example
70
70
  # cookies.permanent[:user_id] = current_user.id
71
71
  def permanent
72
- dup.tap { |c| c.expires = Time.now + 20 * 365 * 24 * 60 * 60 }
72
+ dup.tap { |c| c.expires = Date.today.next_year(20) }
73
73
  end
74
74
 
75
75
  # Set a cookie.
@@ -7,11 +7,13 @@ if defined?(ActiveSupport::IsolatedExecutionState)
7
7
  ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
8
8
  end
9
9
 
10
- # patch Active Record 6.0 to accept the role argument
11
- if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("6.1")
10
+ # patch Active Record 6.0 to accept the role argument;
11
+ # for Active Record 6.1 and 7.0 with `legacy_connection_handling == false` this also
12
+ # allows to correctly handle the `:all` argument by ignoring it
13
+ if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1")
12
14
  %i(active_connections? connection_pool_list clear_active_connections!).each do |m|
13
- ActiveRecord::Base.connection_handler.define_singleton_method(m) do |_ = nil|
14
- super()
15
+ ActiveRecord::Base.connection_handler.define_singleton_method(m) do |role = nil|
16
+ role == :all ? super() : super(role)
15
17
  end
16
18
  end
17
19
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Build OpenAPI specification for the app. Consists of three steps:
5
+ #
6
+ # * `Rage::OpenAPI::Builder` - build a tree of action nodes;
7
+ # * `Rage::OpenAPI::Parser` - parse OpenAPI tags and save the result into the nodes;
8
+ # * `Rage::OpenAPI::Converter` - convert the tree into an OpenAPI spec;
9
+ #
10
+ class Rage::OpenAPI::Builder
11
+ class ParsingError < StandardError
12
+ end
13
+
14
+ # @param namespace [String, Module]
15
+ def initialize(namespace: nil)
16
+ @namespace = namespace.to_s if namespace
17
+
18
+ @collectors_cache = {}
19
+ @nodes = Rage::OpenAPI::Nodes::Root.new
20
+ @routes = Rage.__router.routes.group_by { |route| route[:meta][:controller_class] }
21
+ end
22
+
23
+ def run
24
+ parser = Rage::OpenAPI::Parser.new
25
+
26
+ @routes.each do |controller, routes|
27
+ next if skip_controller?(controller)
28
+
29
+ parent_nodes = fetch_ancestors(controller).map do |klass|
30
+ @nodes.new_parent_node(klass) { |node| parser.parse_dangling_comments(node, parse_class(klass).dangling_comments) }
31
+ end
32
+
33
+ routes.each do |route|
34
+ action = route[:meta][:action]
35
+
36
+ method_comments = fetch_ancestors(controller).filter_map { |klass|
37
+ parse_class(klass).method_comments(action)
38
+ }.first
39
+
40
+ method_node = @nodes.new_method_node(controller, action, parent_nodes)
41
+ method_node.http_method, method_node.http_path = route[:method], route[:path]
42
+
43
+ parser.parse_method_comments(method_node, method_comments)
44
+ end
45
+
46
+ rescue ParsingError
47
+ Rage::OpenAPI.__log_warn "skipping #{controller.name} because of parsing error"
48
+ next
49
+ end
50
+
51
+ Rage::OpenAPI::Converter.new(@nodes).run
52
+ end
53
+
54
+ private
55
+
56
+ def skip_controller?(controller)
57
+ should_skip_controller = controller.nil? || !controller.ancestors.include?(RageController::API)
58
+ should_skip_controller ||= !controller.name.start_with?(@namespace) if @namespace
59
+
60
+ should_skip_controller
61
+ end
62
+
63
+ def fetch_ancestors(controller)
64
+ controller.ancestors.take_while { |klass| klass != RageController::API }
65
+ end
66
+
67
+ def parse_class(klass)
68
+ @collectors_cache[klass] ||= begin
69
+ source_path, _ = Object.const_source_location(klass.name)
70
+ ast = Prism.parse_file(source_path)
71
+
72
+ raise ParsingError if ast.errors.any?
73
+
74
+ # save the "comment => file" association
75
+ ast.comments.each do |comment|
76
+ comment.location.define_singleton_method(:__source_path) { source_path }
77
+ end
78
+
79
+ collector = Rage::OpenAPI::Collector.new(ast.comments)
80
+ ast.value.accept(collector)
81
+
82
+ collector
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Collect all global comments or comments attached to methods in a class.
5
+ # At this point we don't care whether these are Rage OpenAPI comments or not.
6
+ #
7
+ class Rage::OpenAPI::Collector < Prism::Visitor
8
+ # @param comments [Array<Prism::InlineComment>]
9
+ def initialize(comments)
10
+ @comments = comments.dup
11
+ @method_comments = {}
12
+ end
13
+
14
+ def dangling_comments
15
+ @comments
16
+ end
17
+
18
+ def method_comments(method_name)
19
+ @method_comments[method_name.to_s]
20
+ end
21
+
22
+ def visit_def_node(node)
23
+ method_comments = []
24
+ start_line = node.location.start_line - 1
25
+
26
+ loop do
27
+ comment_i = @comments.find_index { |comment| comment.location.start_line == start_line }
28
+ if comment_i
29
+ comment = @comments.delete_at(comment_i)
30
+ method_comments << comment
31
+ start_line -= 1
32
+ end
33
+
34
+ break unless comment
35
+ end
36
+
37
+ @method_comments[node.name.to_s] = method_comments.reverse
38
+
39
+ # reject comments inside methods
40
+ @comments.reject! do |comment|
41
+ comment.location.start_line >= node.location.start_line && comment.location.start_line <= node.location.end_line
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Converter
4
+ # @param nodes [Rage::OpenAPI::Nodes::Root]
5
+ def initialize(nodes)
6
+ @nodes = nodes
7
+ @used_tags = Set.new
8
+ @used_security_schemes = Set.new
9
+
10
+ @spec = {
11
+ "openapi" => "3.0.0",
12
+ "info" => {},
13
+ "components" => {},
14
+ "tags" => [],
15
+ "paths" => {}
16
+ }
17
+ end
18
+
19
+ def run
20
+ @spec["info"] = {
21
+ "version" => @nodes.version || "1.0.0",
22
+ "title" => @nodes.title || build_app_name
23
+ }
24
+
25
+ @spec["paths"] = @nodes.leaves.each_with_object({}) do |node, memo|
26
+ next if node.private || node.parents.any?(&:private)
27
+
28
+ path_parameters = []
29
+ path = node.http_path.gsub(/:(\w+)/) do
30
+ path_parameters << $1
31
+ "{#{$1}}"
32
+ end
33
+
34
+ unless memo.key?(path)
35
+ memo[path] = {}
36
+ path_parameters.each do |parameter|
37
+ (memo[path]["parameters"] ||= []) << {
38
+ "in" => "path",
39
+ "name" => parameter,
40
+ "required" => true,
41
+ "schema" => { "type" => parameter.end_with?("id") ? "integer" : "string" }
42
+ }
43
+ end
44
+ end
45
+
46
+ method = node.http_method.downcase
47
+ memo[path][method] = {
48
+ "summary" => node.summary || "",
49
+ "description" => node.description&.join(" ") || "",
50
+ "deprecated" => !!(node.deprecated || node.parents.any?(&:deprecated)),
51
+ "security" => build_security(node),
52
+ "tags" => build_tags(node)
53
+ }
54
+
55
+ responses = node.parents.reverse.map(&:responses).reduce(&:merge).merge(node.responses)
56
+
57
+ memo[path][method]["responses"] = if responses.any?
58
+ responses.each_with_object({}) do |(status, response), memo|
59
+ memo[status] = if response.nil?
60
+ { "description" => "" }
61
+ elsif response.key?("$ref") && response["$ref"].start_with?("#/components/responses")
62
+ response
63
+ else
64
+ { "description" => "", "content" => { "application/json" => { "schema" => response } } }
65
+ end
66
+ end
67
+ else
68
+ { "200" => { "description" => "" } }
69
+ end
70
+
71
+ if node.request
72
+ if node.request.key?("$ref") && node.request["$ref"].start_with?("#/components/requestBodies")
73
+ memo[path][method]["requestBody"] = node.request
74
+ else
75
+ memo[path][method]["requestBody"] = { "content" => { "application/json" => { "schema" => node.request } } }
76
+ end
77
+ end
78
+ end
79
+
80
+ if @used_security_schemes.any?
81
+ @spec["components"]["securitySchemes"] = @used_security_schemes.each_with_object({}) do |auth_entry, memo|
82
+ memo[auth_entry[:name]] = auth_entry[:definition]
83
+ end
84
+ end
85
+
86
+ if (shared_components = Rage::OpenAPI.__shared_components["components"])
87
+ shared_components.each do |definition_type, definitions|
88
+ (@spec["components"][definition_type] ||= {}).merge!(definitions)
89
+ end
90
+ end
91
+
92
+ @spec["tags"] = @used_tags.sort.map { |tag| { "name" => tag } }
93
+
94
+ @spec
95
+ end
96
+
97
+ private
98
+
99
+ def build_app_name
100
+ basename = Rage.root.basename.to_s
101
+ basename.capitalize.gsub(/[\s\-_]([a-zA-Z0-9]+)/) { " #{$1.capitalize}" }
102
+ end
103
+
104
+ def build_security(node)
105
+ available_before_actions = node.controller.__before_actions_for(node.action.to_sym)
106
+
107
+ node.auth.filter_map do |auth_entry|
108
+ if available_before_actions.any? { |action_entry| action_entry[:name] == auth_entry[:method].to_sym }
109
+ auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
110
+ @used_security_schemes << auth_entry.merge(name: auth_name)
111
+
112
+ { auth_name => [] }
113
+ end
114
+ end
115
+ end
116
+
117
+ def build_tags(node)
118
+ controller_name = node.controller.name.sub(/Controller$/, "")
119
+ namespace_i = controller_name.rindex("::")
120
+
121
+ if namespace_i
122
+ module_name, class_name = controller_name[0...namespace_i], controller_name[namespace_i + 2..]
123
+ else
124
+ module_name, class_name = "", controller_name
125
+ end
126
+
127
+ tag = if module_name =~ /::(V\d+)/
128
+ "#{$1.downcase}/#{class_name}"
129
+ else
130
+ class_name
131
+ end
132
+
133
+ if (custom_tag_resolver = Rage.config.openapi.tag_resolver)
134
+ tag = custom_tag_resolver.call(node.controller, node.action.to_sym, tag)
135
+ end
136
+
137
+ Array(tag).tap do |node_tags|
138
+ @used_tags += node_tags
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="description" content="SwaggerUI" />
7
+ <title>SwaggerUI</title>
8
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@^5/swagger-ui.css" />
9
+ </head>
10
+ <body>
11
+ <div id="swagger-ui"></div>
12
+ <script src="https://unpkg.com/swagger-ui-dist@^5/swagger-ui-bundle.js" crossorigin></script>
13
+ <script>
14
+ window.onload = () => {
15
+ window.ui = SwaggerUIBundle({
16
+ url: '<%= spec_url %>',
17
+ dom_id: '#swagger-ui',
18
+ });
19
+ };
20
+ </script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Nodes::Method
4
+ attr_reader :controller, :action, :parents
5
+ attr_accessor :http_method, :http_path, :summary, :tag, :deprecated, :private, :description,
6
+ :request, :responses, :parameters
7
+
8
+ # @param controller [RageController::API]
9
+ # @param action [String]
10
+ # @param parents [Array<Rage::OpenAPI::Nodes::Parent>]
11
+ def initialize(controller, action, parents)
12
+ @controller = controller
13
+ @action = action
14
+ @parents = parents
15
+
16
+ @responses = {}
17
+ @parameters = []
18
+ end
19
+
20
+ def root
21
+ @parents[0].root
22
+ end
23
+
24
+ def auth
25
+ @parents.flat_map(&:auth)
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Nodes::Parent
4
+ attr_reader :root, :controller
5
+ attr_accessor :deprecated, :private, :auth, :responses
6
+
7
+ # @param root [Rage::OpenAPI::Nodes::Root]
8
+ # @param controller [RageController::API]
9
+ def initialize(root, controller)
10
+ @root = root
11
+ @controller = controller
12
+
13
+ @auth = []
14
+ @responses = {}
15
+ end
16
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Represents a tree of method nodes. The tree consists of:
5
+ #
6
+ # * a root node;
7
+ # * method nodes, each of which represents an action in a controller;
8
+ # * parent nodes attached to one or several method nodes;
9
+ #
10
+ # A method node together with its parent nodes represent a complete inheritance chain.
11
+ #
12
+ # Nodes::Root
13
+ # |
14
+ # Nodes::Parent<ApplicationController>
15
+ # |
16
+ # Nodes::Parent<Api::BaseController>
17
+ # / \
18
+ # Nodes::Parent<Api::V1::UsersController> Nodes::Parent<Api::V2::UsersController>
19
+ # / \ |
20
+ # Nodes::Method<index> Nodes::Method<show> Nodes::Method<show>
21
+ #
22
+ class Rage::OpenAPI::Nodes::Root
23
+ attr_reader :leaves
24
+ attr_accessor :version, :title
25
+
26
+ def initialize
27
+ @parent_nodes_cache = {}
28
+ @leaves = []
29
+ end
30
+
31
+ # @return [Array<Rage::OpenAPI::Nodes::Parent>]
32
+ def parent_nodes
33
+ @parent_nodes_cache.values
34
+ end
35
+
36
+ # @param controller [RageController::API]
37
+ # @param action [String]
38
+ # @param parent_nodes [Array<Rage::OpenAPI::Nodes::Parent>]
39
+ # @return [Rage::OpenAPI::Nodes::Method]
40
+ def new_method_node(controller, action, parent_nodes)
41
+ node = Rage::OpenAPI::Nodes::Method.new(controller, action, parent_nodes)
42
+ @leaves << node
43
+
44
+ node
45
+ end
46
+
47
+ # @param controller [RageController::API]
48
+ # @return [Rage::OpenAPI::Nodes::Parent]
49
+ def new_parent_node(controller)
50
+ @parent_nodes_cache[controller] ||= begin
51
+ node = Rage::OpenAPI::Nodes::Parent.new(self, controller)
52
+ yield(node)
53
+ node
54
+ end
55
+ end
56
+ end