rage-rb 1.22.1 → 1.24.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/CONTRIBUTING.md +240 -0
  4. data/README.md +2 -1
  5. data/lib/rage/all.rb +1 -0
  6. data/lib/rage/application.rb +1 -0
  7. data/lib/rage/cable/cable.rb +20 -15
  8. data/lib/rage/cable/channel.rb +2 -1
  9. data/lib/rage/configuration.rb +229 -27
  10. data/lib/rage/controller/api.rb +17 -33
  11. data/lib/rage/controller/renderers.rb +47 -0
  12. data/lib/rage/deferred/backends/disk.rb +19 -3
  13. data/lib/rage/deferred/deferred.rb +7 -0
  14. data/lib/rage/deferred/metadata.rb +9 -1
  15. data/lib/rage/deferred/queue.rb +5 -4
  16. data/lib/rage/deferred/scheduler.rb +25 -0
  17. data/lib/rage/deferred/task.rb +90 -9
  18. data/lib/rage/errors.rb +86 -0
  19. data/lib/rage/events/subscriber.rb +6 -1
  20. data/lib/rage/internal.rb +45 -0
  21. data/lib/rage/logger/logger.rb +1 -1
  22. data/lib/rage/middleware/fiber_wrapper.rb +1 -0
  23. data/lib/rage/openapi/builder.rb +1 -1
  24. data/lib/rage/openapi/converter.rb +48 -3
  25. data/lib/rage/openapi/nodes/method.rb +2 -1
  26. data/lib/rage/openapi/nodes/root.rb +2 -1
  27. data/lib/rage/openapi/openapi.rb +12 -1
  28. data/lib/rage/openapi/parser.rb +73 -2
  29. data/lib/rage/openapi/parsers/ext/alba.rb +35 -6
  30. data/lib/rage/openapi/parsers/request.rb +2 -2
  31. data/lib/rage/openapi/parsers/response.rb +2 -2
  32. data/lib/rage/openapi/parsers/yaml.rb +27 -5
  33. data/lib/rage/params_parser.rb +2 -2
  34. data/lib/rage/{cable → pubsub}/adapters/redis.rb +43 -23
  35. data/lib/rage/pubsub/pubsub.rb +25 -0
  36. data/lib/rage/rails.rb +16 -0
  37. data/lib/rage/router/README.md +1 -1
  38. data/lib/rage/router/dsl.rb +72 -10
  39. data/lib/rage/sse/application.rb +31 -2
  40. data/lib/rage/sse/sse.rb +96 -0
  41. data/lib/rage/sse/stream.rb +78 -0
  42. data/lib/rage/telemetry/spans/broadcast_sse_stream.rb +50 -0
  43. data/lib/rage/telemetry/spans/process_sse_stream.rb +1 -0
  44. data/lib/rage/telemetry/telemetry.rb +2 -1
  45. data/lib/rage/telemetry/tracer.rb +1 -0
  46. data/lib/rage/uploaded_file.rb +3 -7
  47. data/lib/rage/version.rb +1 -1
  48. data/lib/rage-rb.rb +8 -1
  49. metadata +9 -4
  50. data/lib/rage/cable/adapters/base.rb +0 -16
@@ -31,15 +31,16 @@
31
31
  # ```
32
32
  #
33
33
  module Rage::Deferred::Task
34
- MAX_ATTEMPTS = 5
34
+ MAX_ATTEMPTS = 20
35
35
  private_constant :MAX_ATTEMPTS
36
36
 
37
- BACKOFF_INTERVAL = 5
38
- private_constant :BACKOFF_INTERVAL
39
-
40
37
  # @private
41
38
  CONTEXT_KEY = :__rage_deferred_execution_context
42
39
 
40
+ # @private
41
+ RETRY_IN_CACHE_VAR = :@__rage_deferred_retry_in
42
+ private_constant :RETRY_IN_CACHE_VAR
43
+
43
44
  def perform
44
45
  end
45
46
 
@@ -80,12 +81,14 @@ module Rage::Deferred::Task
80
81
 
81
82
  true
82
83
  rescue Exception => e
84
+ Rage::Errors.report(e)
85
+
83
86
  unless respond_to?(:__deferred_suppress_exception_logging?, true) && __deferred_suppress_exception_logging?
84
87
  Rage.logger.with_context(task_log_context) do
85
88
  Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
86
89
  end
87
90
  end
88
- false
91
+ e
89
92
  end
90
93
 
91
94
  private def restore_log_info(context)
@@ -105,6 +108,62 @@ module Rage::Deferred::Task
105
108
  end
106
109
 
107
110
  module ClassMethods
111
+ # Set the maximum number of retry attempts for this task.
112
+ #
113
+ # @param count [Integer] the maximum number of retry attempts
114
+ # @example
115
+ # class SendWelcomeEmail
116
+ # include Rage::Deferred::Task
117
+ # max_retries 10
118
+ #
119
+ # def perform(email)
120
+ # # ...
121
+ # end
122
+ # end
123
+ def max_retries(count)
124
+ value = Integer(count)
125
+
126
+ if value.negative?
127
+ raise ArgumentError, "max_retries should be a valid non-negative integer"
128
+ end
129
+
130
+ @__max_retries = value
131
+ rescue ArgumentError, TypeError
132
+ raise ArgumentError, "max_retries should be a valid non-negative integer"
133
+ end
134
+
135
+ # Override this method to customize retry behavior per exception.
136
+ #
137
+ # Return an Integer to retry in that many seconds.
138
+ # Return `super` to use the default exponential backoff.
139
+ # Return `false` or `nil` to abort retries.
140
+ #
141
+ # @param exception [Exception] the exception that caused the failure
142
+ # @param attempt [Integer] the current attempt number (1-indexed)
143
+ # @return [Integer, false, nil] the retry interval in seconds, or false/nil to abort
144
+ # @example
145
+ # class ProcessPayment
146
+ # include Rage::Deferred::Task
147
+ #
148
+ # def self.retry_interval(exception, attempt:)
149
+ # case exception
150
+ # when TemporaryNetworkError
151
+ # 10 # Retry in 10 seconds
152
+ # when InvalidDataError
153
+ # false # Do not retry
154
+ # else
155
+ # super # Default backoff strategy
156
+ # end
157
+ # end
158
+ #
159
+ # def perform(payment_id)
160
+ # # ...
161
+ # end
162
+ # end
163
+ def retry_interval(exception, attempt:)
164
+ __default_backoff(attempt)
165
+ end
166
+
108
167
  def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
109
168
  context = Rage::Deferred::Context.build(self, args, kwargs)
110
169
 
@@ -118,13 +177,35 @@ module Rage::Deferred::Task
118
177
  end
119
178
 
120
179
  # @private
121
- def __should_retry?(attempts)
122
- attempts < MAX_ATTEMPTS
180
+ def __next_retry_in(attempts, exception)
181
+ f = Fiber.current
182
+
183
+ if f.instance_variable_defined?(RETRY_IN_CACHE_VAR)
184
+ f.instance_variable_get(RETRY_IN_CACHE_VAR)
185
+ else
186
+ f.instance_variable_set(RETRY_IN_CACHE_VAR, __calculate_next_retry_in(attempts, exception))
187
+ end
188
+ end
189
+
190
+ # @private
191
+ def __calculate_next_retry_in(attempts, exception)
192
+ max = @__max_retries || MAX_ATTEMPTS
193
+ return if attempts > max
194
+
195
+ interval = retry_interval(exception, attempt: attempts)
196
+ return if !interval
197
+
198
+ unless interval.is_a?(Numeric)
199
+ Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff")
200
+ return __default_backoff(attempts)
201
+ end
202
+
203
+ interval
123
204
  end
124
205
 
125
206
  # @private
126
- def __next_retry_in(attempts)
127
- rand(BACKOFF_INTERVAL * 2**attempts.to_i) + 1
207
+ def __default_backoff(attempt)
208
+ (attempt**4) + 10 + (rand(15) * attempt)
128
209
  end
129
210
  end
130
211
  end
data/lib/rage/errors.rb CHANGED
@@ -1,4 +1,87 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rage::Errors
4
+ ReporterEntry = Struct.new(:reporter, :method_name)
5
+ private_constant :ReporterEntry
6
+
7
+ @reporters = []
8
+ @next_reporter_id = 0
9
+
10
+ class << self
11
+ # Forward an exception to all registered reporters.
12
+ #
13
+ # @param exception [Exception]
14
+ # @param context [Hash]
15
+ # @return [nil]
16
+ def report(exception, context: {})
17
+ return if @reporters.empty?
18
+ return if exception.instance_variable_defined?(:@_rage_error_reported)
19
+
20
+ ensure_backtrace(exception)
21
+
22
+ @reporters.each do |entry|
23
+ __send__(entry.method_name, entry.reporter, exception, context)
24
+ rescue => e
25
+ Rage.logger.error("Error reporter #{entry.reporter.class} failed while reporting #{exception.class}: #{e.class} (#{e.message})")
26
+ end
27
+
28
+ exception.instance_variable_set(:@_rage_error_reported, true) unless exception.frozen?
29
+
30
+ nil
31
+ end
32
+
33
+ # @private
34
+ def __register_reporter(reporter)
35
+ raise ArgumentError, "error reporter must respond to #call" unless reporter.respond_to?(:call)
36
+
37
+ reporter_id = @next_reporter_id
38
+ @next_reporter_id += 1
39
+ method_name = :"__report_#{reporter_id}"
40
+
41
+ arguments = Rage::Internal.build_arguments(
42
+ reporter.method(:call),
43
+ { context: "context" }
44
+ )
45
+ call_arguments = arguments.empty? ? "" : ", #{arguments}"
46
+
47
+ singleton_class.class_eval <<~RUBY, __FILE__, __LINE__ + 1
48
+ def #{method_name}(reporter, exception, context)
49
+ reporter.call(exception#{call_arguments})
50
+ end
51
+ RUBY
52
+
53
+ @reporters << ReporterEntry.new(reporter, method_name)
54
+
55
+ self
56
+ end
57
+
58
+ # @private
59
+ def __unregister_reporter(reporter)
60
+ @reporters.delete_if do |entry|
61
+ next false unless entry.reporter == reporter
62
+
63
+ singleton_class.remove_method(entry.method_name) if singleton_class.method_defined?(entry.method_name)
64
+ true
65
+ end
66
+
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ def ensure_backtrace(exception)
73
+ return if exception.frozen?
74
+ return unless exception.backtrace.nil?
75
+
76
+ begin
77
+ raise exception
78
+ rescue exception.class
79
+ end
80
+ end
81
+
82
+ private :__register_reporter, :__unregister_reporter
83
+ end
84
+
2
85
  class BadRequest < StandardError
3
86
  end
4
87
 
@@ -10,4 +93,7 @@ module Rage::Errors
10
93
 
11
94
  class InvalidCustomProxy < StandardError
12
95
  end
96
+
97
+ class AmbiguousRenderError < StandardError
98
+ end
13
99
  end
@@ -90,7 +90,12 @@ module Rage::Events::Subscriber
90
90
  Rage.logger.with_context(self.class.__log_context) do
91
91
  Rage.logger.error("Subscriber failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
92
92
  end
93
- raise e if self.class.__is_deferred
93
+
94
+ if self.class.__is_deferred
95
+ raise e
96
+ else
97
+ Rage::Errors.report(e)
98
+ end
94
99
  end
95
100
 
96
101
  private
data/lib/rage/internal.rb CHANGED
@@ -44,8 +44,53 @@ class Rage::Internal
44
44
  }.join(", ")
45
45
  end
46
46
 
47
+ # Generate a stream name based on the provided object.
48
+ # @param streamables [#id, String, Symbol, Numeric, Array] an object that will be used to generate the stream name
49
+ # @return [String] the generated stream name
50
+ # @raise [ArgumentError] if the provided object cannot be used to generate a stream name
51
+ def stream_name_for(streamables)
52
+ return streamables if streamables.is_a?(String)
53
+
54
+ name_segments = Array(streamables).map do |streamable|
55
+ if streamable.respond_to?(:id)
56
+ "#{streamable.class.name}:#{streamable.id}"
57
+ elsif streamable.is_a?(String) || streamable.is_a?(Symbol) || streamable.is_a?(Numeric)
58
+ streamable
59
+ else
60
+ raise ArgumentError, "Unable to generate stream name. Expected an object that responds to `id`, got: #{streamable.class}"
61
+ end
62
+ end
63
+
64
+ name_segments.join(":")
65
+ end
66
+
67
+ LOCK_FILE_SUFFIX = rand(0x100000000).to_s(36)
68
+
69
+ # Pick a worker process to execute a block of code.
70
+ # This is useful for ensuring that certain code is only executed by a single worker in a multi-worker setup, e.g. for broadcasting messages to known streams or for running periodic tasks.
71
+ # @yield The block of code to be executed by the picked worker
72
+ def pick_a_worker(purpose:, &block)
73
+ attempt = proc do
74
+ lock_path = Pathname.new(Dir.tmpdir).join("rage-#{purpose}-lock-#{LOCK_FILE_SUFFIX}")
75
+
76
+ lock_file = File.open(lock_path, File::CREAT | File::WRONLY)
77
+
78
+ if lock_file.flock(File::LOCK_EX | File::LOCK_NB)
79
+ Iodine.on_state(:on_finish) { File.unlink(lock_file) if File.exist?(lock_file) }
80
+ worker_locks << lock_file
81
+ block.call
82
+ end
83
+ end
84
+
85
+ Iodine.running? ? attempt.call : Iodine.on_state(:on_start) { attempt.call }
86
+ end
87
+
47
88
  private
48
89
 
90
+ def worker_locks
91
+ @worker_locks ||= []
92
+ end
93
+
49
94
  def dynamic_name_seed
50
95
  @dynamic_name_seed ||= ("a".."j").to_a.permutation
51
96
  end
@@ -28,7 +28,7 @@ require "logger"
28
28
  # # => [fecbba0735355738] timestamp=2023-10-19T11:12:56+00:00 pid=1825 level=info cache_key=mykey message=cache miss
29
29
  # ```
30
30
  #
31
- # `Rage::Logger` also implements the interface of Ruby's native {https://ruby-doc.org/3.2.2/stdlibs/logger/Logger.html Logger}:
31
+ # `Rage::Logger` also implements the interface of Ruby's native {https://ruby-doc.org/3.4.1/stdlibs/logger/Logger.html Logger}:
32
32
  # ```ruby
33
33
  # Rage.logger.info("Initializing")
34
34
  # Rage.logger.debug { "This is a " + potentially + " expensive operation" }
@@ -20,6 +20,7 @@ class Rage::FiberWrapper
20
20
  rescue Exception => e
21
21
  exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
22
22
  Rage.logger << exception_str
23
+ Rage::Errors.report(e)
23
24
  if Rage.env.development?
24
25
  [500, {}, [exception_str]]
25
26
  else
@@ -21,7 +21,7 @@ class Rage::OpenAPI::Builder
21
21
  end
22
22
 
23
23
  def run
24
- parser = Rage::OpenAPI::Parser.new
24
+ parser = Rage::OpenAPI::Parser.new(@nodes)
25
25
 
26
26
  @routes.each do |controller, routes|
27
27
  next if skip_controller?(controller)
@@ -56,7 +56,47 @@ class Rage::OpenAPI::Converter
56
56
  }
57
57
 
58
58
  if node.parameters.any?
59
- memo[path][method]["parameters"] = build_parameters(node)
59
+ has_file_param = node.parameters.values.any? { |p| !p.key?(:ref) && p[:type] && p[:type]["format"] == "binary" }
60
+
61
+ if has_file_param
62
+ schema_properties = {}
63
+ schema_required = []
64
+ query_ref_parameters = []
65
+
66
+ # When file params are present, non-ref params become part of the multipart/form-data
67
+ # request body schema. Shared refs (e.g., #/components/parameters/...) are kept as
68
+ # regular parameter references since we can't inline their definitions into the schema.
69
+ node.parameters.each do |param_name, param_info|
70
+ if param_info.key?(:ref)
71
+ # shared parameter refs stay as top-level parameters
72
+ query_ref_parameters << param_info[:ref]
73
+ else
74
+ # inline params become properties in the multipart schema
75
+ property_schema = get_param_type_spec(param_name, param_info[:type]).dup
76
+ if param_info[:description] && !param_info[:description].empty?
77
+ property_schema["description"] = param_info[:description]
78
+ end
79
+
80
+ schema_properties[param_name] = property_schema
81
+ schema_required << param_name if param_info[:required]
82
+ end
83
+ end
84
+
85
+ memo[path][method]["requestBody"] = {
86
+ "content" => {
87
+ "multipart/form-data" => {
88
+ "schema" => {
89
+ "type" => "object",
90
+ "properties" => schema_properties
91
+ }.tap { |s| s["required"] = schema_required if schema_required.any? }
92
+ }
93
+ }
94
+ }
95
+
96
+ memo[path][method]["parameters"] = query_ref_parameters if query_ref_parameters.any?
97
+ else
98
+ memo[path][method]["parameters"] = build_parameters(node)
99
+ end
60
100
  end
61
101
 
62
102
  responses = node.parents.reverse.map(&:responses).reduce(&:merge).merge(node.responses)
@@ -79,7 +119,8 @@ class Rage::OpenAPI::Converter
79
119
  if node.request.key?("$ref") && node.request["$ref"].start_with?("#/components/requestBodies")
80
120
  memo[path][method]["requestBody"] = node.request
81
121
  else
82
- memo[path][method]["requestBody"] = { "content" => { "application/json" => { "schema" => node.request } } }
122
+ memo[path][method]["requestBody"] ||= {}
123
+ (memo[path][method]["requestBody"]["content"] ||= {})["application/json"] = { "schema" => node.request }
83
124
  end
84
125
  end
85
126
  end
@@ -96,6 +137,10 @@ class Rage::OpenAPI::Converter
96
137
  end
97
138
  end
98
139
 
140
+ if (dynamic_schemas = @nodes.schema_registry).any?
141
+ (@spec["components"]["schemas"] ||= {}).merge!(dynamic_schemas)
142
+ end
143
+
99
144
  @spec["tags"] = @used_tags.sort.map { |tag| { "name" => tag } }
100
145
 
101
146
  @spec
@@ -136,7 +181,7 @@ class Rage::OpenAPI::Converter
136
181
  @used_security_schemes << auth_entry.merge(name: auth_name)
137
182
  end
138
183
 
139
- { auth_name => [] }
184
+ { auth_name => node.auth_scopes.fetch(auth_name, []) }
140
185
  end
141
186
  end
142
187
  end
@@ -3,7 +3,7 @@
3
3
  class Rage::OpenAPI::Nodes::Method
4
4
  attr_reader :controller, :action, :parents
5
5
  attr_accessor :http_method, :http_path, :summary, :tag, :deprecated, :private, :description,
6
- :request, :responses, :parameters
6
+ :request, :responses, :parameters, :auth_scopes
7
7
 
8
8
  # @param controller [RageController::API]
9
9
  # @param action [String]
@@ -15,6 +15,7 @@ class Rage::OpenAPI::Nodes::Method
15
15
 
16
16
  @responses = {}
17
17
  @parameters = {}
18
+ @auth_scopes = {}
18
19
  end
19
20
 
20
21
  def root
@@ -20,12 +20,13 @@
20
20
  # Nodes::Method<index> Nodes::Method<show> Nodes::Method<show>
21
21
  #
22
22
  class Rage::OpenAPI::Nodes::Root
23
- attr_reader :leaves
23
+ attr_reader :leaves, :schema_registry
24
24
  attr_accessor :version, :title
25
25
 
26
26
  def initialize
27
27
  @parent_nodes_cache = {}
28
28
  @leaves = []
29
+ @schema_registry = {}
29
30
  end
30
31
 
31
32
  # @return [Array<Rage::OpenAPI::Nodes::Parent>]
@@ -120,7 +120,7 @@ module Rage::OpenAPI
120
120
 
121
121
  # @private
122
122
  def self.__try_parse_collection(str)
123
- if str =~ /^Array<([\w\s:\(\)]+)>$/ || str =~ /^\[([\w\s:\(\)]+)\]$/
123
+ if str =~ /^Array<([\w\s:\(\),]+)>$/ || str =~ /^\[([\w\s:\(\),]+)\]$/
124
124
  [true, $1]
125
125
  else
126
126
  [false, str]
@@ -153,11 +153,22 @@ module Rage::OpenAPI
153
153
  { "type" => "string", "format" => "date-time" }
154
154
  when "String"
155
155
  { "type" => "string" }
156
+ when "File"
157
+ { "type" => "string", "format" => "binary" }
156
158
  else
157
159
  { "type" => "string" } if default
158
160
  end
159
161
  end
160
162
 
163
+ # @private
164
+ def self.__resolve_resource(klass_str, namespace)
165
+ return nil if klass_str.nil?
166
+ namespace.const_get(klass_str)
167
+ rescue NameError
168
+ __log_warn("could not resolve resource: #{klass_str}")
169
+ nil
170
+ end
171
+
161
172
  # @private
162
173
  def self.__log_warn(log)
163
174
  puts "[OpenAPI] WARNING: #{log}"
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OpenAPI::Parser
4
+ # @param root [Rage::OpenAPI::Nodes::Root]
5
+ def initialize(root)
6
+ @root = root
7
+ end
8
+
4
9
  # @param node [Rage::OpenAPI::Nodes::Parent]
5
10
  # @param comments [Array<Prism::InlineComment>]
6
11
  def parse_dangling_comments(node, comments)
@@ -125,7 +130,8 @@ class Rage::OpenAPI::Parser
125
130
  else
126
131
  parsed = Rage::OpenAPI::Parsers::Request.parse(
127
132
  request,
128
- namespace: Rage::OpenAPI.__module_parent(node.controller)
133
+ namespace: Rage::OpenAPI.__module_parent(node.controller),
134
+ root: @root
129
135
  )
130
136
 
131
137
  if parsed
@@ -138,6 +144,9 @@ class Rage::OpenAPI::Parser
138
144
  elsif expression =~ /@param\s/
139
145
  parse_param_tag(expression, node, comments[i])
140
146
 
147
+ elsif expression =~ /@auth_scope\s/
148
+ parse_auth_scope_tag(expression, node, comments[i])
149
+
141
150
  elsif expression =~ /@internal\b/
142
151
  # no-op
143
152
  children = find_children(comments[i + 1..], node)
@@ -203,7 +212,8 @@ class Rage::OpenAPI::Parser
203
212
  else
204
213
  parsed = Rage::OpenAPI::Parsers::Response.parse(
205
214
  response_data,
206
- namespace: Rage::OpenAPI.__module_parent(node.controller)
215
+ namespace: Rage::OpenAPI.__module_parent(node.controller),
216
+ root: @root
207
217
  )
208
218
 
209
219
  if parsed
@@ -256,6 +266,67 @@ class Rage::OpenAPI::Parser
256
266
  end
257
267
  end
258
268
 
269
+ def parse_auth_scope_tag(expression, node, comment)
270
+ content = expression.split(" ", 2)[1]
271
+
272
+ unless content
273
+ Rage::OpenAPI.__log_warn "invalid `@auth_scope` tag detected at #{location_msg(comment)}; expected [scope1, scope2] syntax"
274
+ return
275
+ end
276
+
277
+ parsed = YAML.safe_load(content)
278
+
279
+ if parsed.is_a?(Array)
280
+ scheme_name = nil
281
+ scopes = parsed.map(&:to_s)
282
+ elsif parsed.is_a?(String)
283
+ scheme_name = parsed.split(" ", 2)[0]
284
+ scopes_str = content.split(" ", 2)[1]
285
+
286
+ unless scopes_str
287
+ Rage::OpenAPI.__log_warn "invalid `@auth_scope` tag detected at #{location_msg(comment)}; expected [scope1, scope2] syntax"
288
+ return
289
+ end
290
+
291
+ scopes = YAML.safe_load(scopes_str)
292
+
293
+ unless scopes.is_a?(Array)
294
+ Rage::OpenAPI.__log_warn "invalid `@auth_scope` tag detected at #{location_msg(comment)}; expected [scope1, scope2] syntax"
295
+ return
296
+ end
297
+
298
+ scopes = scopes.map(&:to_s)
299
+ else
300
+ Rage::OpenAPI.__log_warn "invalid `@auth_scope` tag detected at #{location_msg(comment)}; expected [scope1, scope2] syntax"
301
+ return
302
+ end
303
+
304
+ if scheme_name.nil?
305
+ auth_entries = node.auth
306
+ if auth_entries.empty?
307
+ Rage::OpenAPI.__log_warn "no auth schemes found for `@auth_scope` shorthand at #{location_msg(comment)}; define an @auth tag on the controller first"
308
+ return
309
+ elsif auth_entries.length > 1
310
+ Rage::OpenAPI.__log_warn "ambiguous `@auth_scope` shorthand at #{location_msg(comment)}; multiple auth schemes found, specify the scheme name explicitly"
311
+ return
312
+ end
313
+ scheme_name = auth_entries[0][:name]
314
+ else
315
+ auth_names = node.auth.map { |e| e[:name] }
316
+ unless auth_names.include?(scheme_name)
317
+ Rage::OpenAPI.__log_warn "unknown scheme `#{scheme_name}` in `@auth_scope` tag at #{location_msg(comment)}; available schemes: #{auth_names.join(", ")}"
318
+ return
319
+ end
320
+ end
321
+
322
+ if node.auth_scopes.key?(scheme_name)
323
+ Rage::OpenAPI.__log_warn "duplicate `@auth_scope` tag for `#{scheme_name}` detected at #{location_msg(comment)}"
324
+ return
325
+ end
326
+
327
+ node.auth_scopes[scheme_name] = scopes
328
+ end
329
+
259
330
  def parse_param_tag(expression, node, comment)
260
331
  param = expression[7..].strip
261
332
 
@@ -3,8 +3,10 @@
3
3
  class Rage::OpenAPI::Parsers::Ext::Alba
4
4
  attr_reader :namespace
5
5
 
6
- def initialize(namespace: Object, **)
6
+ def initialize(namespace: Object, root: Rage::OpenAPI::Nodes::Root.new, **)
7
7
  @namespace = namespace
8
+ @root = root
9
+ @parsing_stack = Set.new
8
10
  end
9
11
 
10
12
  def known_definition?(str)
@@ -15,10 +17,27 @@ class Rage::OpenAPI::Parsers::Ext::Alba
15
17
  end
16
18
 
17
19
  def parse(klass_str)
18
- __parse(klass_str).build_schema
20
+ _, raw_klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
21
+ visitor = __parse(klass_str)
22
+
23
+ if @root.schema_registry.key?(raw_klass_str)
24
+ clean = { "type" => "object" }
25
+ clean["properties"] = visitor.schema if visitor.schema.any?
26
+ @root.schema_registry[raw_klass_str] = clean
27
+ end
28
+
29
+ visitor.build_schema
19
30
  end
20
31
 
21
32
  def __parse_nested(klass_str)
33
+ is_collection, raw_klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
34
+
35
+ if @parsing_stack.include?(raw_klass_str)
36
+ @root.schema_registry[raw_klass_str] ||= nil
37
+ ref = { "$ref" => "#/components/schemas/#{raw_klass_str}" }
38
+ return is_collection ? { "type" => "array", "items" => ref } : ref
39
+ end
40
+
22
41
  __parse(klass_str).tap { |visitor|
23
42
  visitor.root_key = visitor.root_key_for_collection = visitor.root_key_proc = visitor.key_transformer = nil
24
43
  }.build_schema
@@ -27,6 +46,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
27
46
  def __parse(klass_str)
28
47
  is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
29
48
 
49
+ # return an empty visitor if we're already parsing this class;
50
+ # this serves as a recursion guard, specifically for the inheritance logic in `Visitor#visit_class_node`
51
+ if @parsing_stack.include?(klass_str)
52
+ return Visitor.new(self, is_collection)
53
+ end
54
+ @parsing_stack.add(klass_str)
55
+
30
56
  klass = @namespace.const_get(klass_str)
31
57
  source_path, _ = Object.const_source_location(klass.name)
32
58
  ast = Prism.parse_file(source_path)
@@ -34,6 +60,8 @@ class Rage::OpenAPI::Parsers::Ext::Alba
34
60
  visitor = Visitor.new(self, is_collection)
35
61
  ast.value.accept(visitor)
36
62
 
63
+ @parsing_stack.delete(klass_str)
64
+
37
65
  visitor
38
66
  end
39
67
 
@@ -151,12 +179,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
151
179
  with_inner_segment(key, is_array:) { visit(node.block) }
152
180
  else
153
181
  resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(association.to_s)}Resource")
154
- is_valid_resource = @parser.namespace.const_get(resource) rescue false
182
+ resolved = Rage::OpenAPI.__resolve_resource(resource, @parser.namespace)
155
183
 
156
- @segment[key] = if is_array
157
- @parser.__parse_nested(is_valid_resource ? "[#{resource}]" : "[Rage]") # TODO
184
+ @segment[key] = if resolved
185
+ is_array ? @parser.__parse_nested("[#{resource}]") : @parser.__parse_nested(resource)
158
186
  else
159
- @parser.__parse_nested(is_valid_resource ? resource : "Rage")
187
+ base = { "type" => "object" }
188
+ is_array ? { "type" => "array", "items" => base } : base
160
189
  end
161
190
  end
162
191
 
@@ -7,9 +7,9 @@ class Rage::OpenAPI::Parsers::Request
7
7
  Rage::OpenAPI::Parsers::Ext::ActiveRecord
8
8
  ]
9
9
 
10
- def self.parse(request_tag, namespace:)
10
+ def self.parse(request_tag, namespace:, root:)
11
11
  parser = AVAILABLE_PARSERS.find do |parser_class|
12
- parser = parser_class.new(namespace:)
12
+ parser = parser_class.new(namespace:, root:)
13
13
  break parser if parser.known_definition?(request_tag)
14
14
  end
15
15