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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/CONTRIBUTING.md +240 -0
- data/README.md +2 -1
- data/lib/rage/all.rb +1 -0
- data/lib/rage/application.rb +1 -0
- data/lib/rage/cable/cable.rb +20 -15
- data/lib/rage/cable/channel.rb +2 -1
- data/lib/rage/configuration.rb +229 -27
- data/lib/rage/controller/api.rb +17 -33
- data/lib/rage/controller/renderers.rb +47 -0
- data/lib/rage/deferred/backends/disk.rb +19 -3
- data/lib/rage/deferred/deferred.rb +7 -0
- data/lib/rage/deferred/metadata.rb +9 -1
- data/lib/rage/deferred/queue.rb +5 -4
- data/lib/rage/deferred/scheduler.rb +25 -0
- data/lib/rage/deferred/task.rb +90 -9
- data/lib/rage/errors.rb +86 -0
- data/lib/rage/events/subscriber.rb +6 -1
- data/lib/rage/internal.rb +45 -0
- data/lib/rage/logger/logger.rb +1 -1
- data/lib/rage/middleware/fiber_wrapper.rb +1 -0
- data/lib/rage/openapi/builder.rb +1 -1
- data/lib/rage/openapi/converter.rb +48 -3
- data/lib/rage/openapi/nodes/method.rb +2 -1
- data/lib/rage/openapi/nodes/root.rb +2 -1
- data/lib/rage/openapi/openapi.rb +12 -1
- data/lib/rage/openapi/parser.rb +73 -2
- data/lib/rage/openapi/parsers/ext/alba.rb +35 -6
- data/lib/rage/openapi/parsers/request.rb +2 -2
- data/lib/rage/openapi/parsers/response.rb +2 -2
- data/lib/rage/openapi/parsers/yaml.rb +27 -5
- data/lib/rage/params_parser.rb +2 -2
- data/lib/rage/{cable → pubsub}/adapters/redis.rb +43 -23
- data/lib/rage/pubsub/pubsub.rb +25 -0
- data/lib/rage/rails.rb +16 -0
- data/lib/rage/router/README.md +1 -1
- data/lib/rage/router/dsl.rb +72 -10
- data/lib/rage/sse/application.rb +31 -2
- data/lib/rage/sse/sse.rb +96 -0
- data/lib/rage/sse/stream.rb +78 -0
- data/lib/rage/telemetry/spans/broadcast_sse_stream.rb +50 -0
- data/lib/rage/telemetry/spans/process_sse_stream.rb +1 -0
- data/lib/rage/telemetry/telemetry.rb +2 -1
- data/lib/rage/telemetry/tracer.rb +1 -0
- data/lib/rage/uploaded_file.rb +3 -7
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +8 -1
- metadata +9 -4
- data/lib/rage/cable/adapters/base.rb +0 -16
data/lib/rage/deferred/task.rb
CHANGED
|
@@ -31,15 +31,16 @@
|
|
|
31
31
|
# ```
|
|
32
32
|
#
|
|
33
33
|
module Rage::Deferred::Task
|
|
34
|
-
MAX_ATTEMPTS =
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
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
|
|
127
|
-
rand(
|
|
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
|
-
|
|
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
|
data/lib/rage/logger/logger.rb
CHANGED
|
@@ -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.
|
|
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" }
|
data/lib/rage/openapi/builder.rb
CHANGED
|
@@ -56,7 +56,47 @@ class Rage::OpenAPI::Converter
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
if node.parameters.any?
|
|
59
|
-
|
|
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"]
|
|
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>]
|
data/lib/rage/openapi/openapi.rb
CHANGED
|
@@ -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}"
|
data/lib/rage/openapi/parser.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
182
|
+
resolved = Rage::OpenAPI.__resolve_resource(resource, @parser.namespace)
|
|
155
183
|
|
|
156
|
-
@segment[key] = if
|
|
157
|
-
@parser.__parse_nested(
|
|
184
|
+
@segment[key] = if resolved
|
|
185
|
+
is_array ? @parser.__parse_nested("[#{resource}]") : @parser.__parse_nested(resource)
|
|
158
186
|
else
|
|
159
|
-
|
|
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
|
|