rage-rb 1.23.0 → 1.25.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/CONTRIBUTING.md +240 -0
  4. data/README.md +66 -123
  5. data/lib/rage/application.rb +1 -0
  6. data/lib/rage/cable/cable.rb +20 -15
  7. data/lib/rage/cable/channel.rb +2 -1
  8. data/lib/rage/configuration.rb +166 -29
  9. data/lib/rage/controller/api.rb +10 -34
  10. data/lib/rage/cookies.rb +1 -1
  11. data/lib/rage/deferred/deferred.rb +7 -0
  12. data/lib/rage/deferred/metadata.rb +8 -0
  13. data/lib/rage/deferred/scheduler.rb +25 -0
  14. data/lib/rage/deferred/task.rb +19 -5
  15. data/lib/rage/errors.rb +83 -0
  16. data/lib/rage/events/subscriber.rb +6 -1
  17. data/lib/rage/fiber.rb +14 -23
  18. data/lib/rage/fiber_scheduler.rb +51 -6
  19. data/lib/rage/internal.rb +15 -6
  20. data/lib/rage/middleware/fiber_wrapper.rb +1 -0
  21. data/lib/rage/openapi/builder.rb +1 -1
  22. data/lib/rage/openapi/converter.rb +5 -1
  23. data/lib/rage/openapi/nodes/method.rb +2 -1
  24. data/lib/rage/openapi/nodes/root.rb +2 -1
  25. data/lib/rage/openapi/openapi.rb +33 -1
  26. data/lib/rage/openapi/parser.rb +73 -2
  27. data/lib/rage/openapi/parsers/ext/alba.rb +30 -2
  28. data/lib/rage/openapi/parsers/ext/blueprinter.rb +110 -0
  29. data/lib/rage/openapi/parsers/request.rb +2 -2
  30. data/lib/rage/openapi/parsers/response.rb +3 -2
  31. data/lib/rage/openapi/parsers/yaml.rb +27 -5
  32. data/lib/rage/params_parser.rb +2 -2
  33. data/lib/rage/pubsub/adapters/redis.rb +2 -1
  34. data/lib/rage/router/constrainer.rb +1 -1
  35. data/lib/rage/router/dsl.rb +7 -2
  36. data/lib/rage/sse/application.rb +1 -0
  37. data/lib/rage/telemetry/tracer.rb +1 -0
  38. data/lib/rage/version.rb +1 -1
  39. data/lib/rage-rb.rb +6 -0
  40. metadata +6 -4
  41. data/lib/rage/cable/adapters/base.rb +0 -16
  42. data/lib/rage/cable/adapters/redis.rb +0 -128
@@ -5,14 +5,20 @@ require "resolv"
5
5
  class Rage::FiberScheduler
6
6
  MAX_READ = 65536
7
7
 
8
+ # Initialize the scheduler, storing the root fiber and an empty DNS cache.
8
9
  def initialize
9
10
  @root_fiber = Fiber.current
10
11
  @dns_cache = {}
11
12
  end
12
13
 
14
+ # Wait for I/O events on a file descriptor, yielding the fiber until ready or timeout.
13
15
  def io_wait(io, events, timeout = nil)
14
16
  f = Fiber.current
15
- ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) { |err| f.resume(err) }
17
+ gen = (f.__wait_generation += 1)
18
+
19
+ ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) do |err|
20
+ f.resume(err) if f.alive? && gen == f.__wait_generation
21
+ end
16
22
 
17
23
  err = Fiber.defer(io.fileno)
18
24
  if err == false || (err && err < 0)
@@ -22,6 +28,7 @@ class Rage::FiberScheduler
22
28
  end
23
29
  end
24
30
 
31
+ # Read data from an I/O object into a buffer, pausing the fiber between reads.
25
32
  def io_read(io, buffer, length, offset = 0)
26
33
  length_to_read = if length == 0
27
34
  buffer.size > MAX_READ ? MAX_READ : buffer.size
@@ -51,6 +58,7 @@ class Rage::FiberScheduler
51
58
  end
52
59
 
53
60
  unless ENV["RAGE_DISABLE_IO_WRITE"]
61
+ # Write data from a buffer to an I/O object.
54
62
  def io_write(io, buffer, length, offset = 0)
55
63
  bytes_to_write = length
56
64
  bytes_to_write = buffer.size if length == 0
@@ -61,6 +69,7 @@ class Rage::FiberScheduler
61
69
  end
62
70
  end
63
71
 
72
+ # Pause the current fiber for the specified duration.
64
73
  def kernel_sleep(duration = nil)
65
74
  block(nil, duration || 0)
66
75
  Fiber.pause if duration.nil? || duration < 1
@@ -80,6 +89,7 @@ class Rage::FiberScheduler
80
89
  # result
81
90
  # end
82
91
 
92
+ # Resolve a hostname to IP addresses, caching results for 60 seconds.
83
93
  def address_resolve(hostname)
84
94
  @dns_cache[hostname] ||= begin
85
95
  ::Iodine.run_after(60_000) do
@@ -90,14 +100,18 @@ class Rage::FiberScheduler
90
100
  end
91
101
  end
92
102
 
103
+ # Block the current fiber until unblocked or timeout.
93
104
  def block(_blocker, timeout = nil)
94
- f, fulfilled, channel = Fiber.current, false, Fiber.current.__block_channel(true)
105
+ f, fulfilled = Fiber.current, false
106
+
107
+ gen = (f.__wait_generation += 1)
108
+ channel = f.__block_channel = "block:#{f.object_id}:#{gen}"
95
109
 
96
110
  resume_fiber_block = proc do
97
111
  unless fulfilled
98
112
  fulfilled = true
99
113
  ::Iodine.defer { ::Iodine.unsubscribe(channel) }
100
- f.resume if f.alive?
114
+ f.resume if f.alive? && gen == f.__wait_generation
101
115
  end
102
116
  end
103
117
 
@@ -109,10 +123,38 @@ class Rage::FiberScheduler
109
123
  Fiber.yield
110
124
  end
111
125
 
126
+ # Unblock a fiber by publishing to its block channel.
112
127
  def unblock(_blocker, fiber)
113
- ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS)
128
+ ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS) if fiber.__block_channel
129
+ end
130
+
131
+ # Interrupt a fiber by incrementing its generation and raising an exception.
132
+ def fiber_interrupt(fiber, exception)
133
+ fiber.__wait_generation += 1
134
+ fiber.raise(exception)
135
+ end
136
+
137
+ if defined?(Iodine::WorkerPool)
138
+ module BlockingOperationWait
139
+ # Offload a native call to the worker pool, yielding until complete.
140
+ def blocking_operation_wait(work)
141
+ f = Fiber.current
142
+ gen = (f.__wait_generation += 1)
143
+
144
+ worker_pool.enqueue(work) do
145
+ f.resume if f.alive? && gen == f.__wait_generation
146
+ end
147
+
148
+ Fiber.yield
149
+ end
150
+
151
+ private def worker_pool
152
+ @worker_pool ||= Iodine::WorkerPool.new(Rage.config.blocking_operation_pool.size)
153
+ end
154
+ end
114
155
  end
115
156
 
157
+ # Create and schedule a new non-blocking fiber, handling request and user-spawned fibers differently.
116
158
  def fiber(&block)
117
159
  parent = Fiber.current
118
160
 
@@ -131,19 +173,22 @@ class Rage::FiberScheduler
131
173
  Fiber.current.__set_result(block.call)
132
174
  end
133
175
  # send a message for `Fiber.await` to work
134
- Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.alive?
176
+ Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.__await_channel
135
177
  rescue Exception => e
136
178
  Fiber.current.__set_err(e)
137
- Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
179
+ Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.__await_channel
138
180
  end
139
181
  end
140
182
 
183
+ fiber.__wait_generation = 0
141
184
  fiber.resume
142
185
 
143
186
  fiber
144
187
  end
145
188
 
189
+ # Clean up by closing the worker pool and Iodine scheduler.
146
190
  def close
191
+ @worker_pool&.close
147
192
  ::Iodine::Scheduler.close
148
193
  end
149
194
  end
data/lib/rage/internal.rb CHANGED
@@ -64,24 +64,33 @@ class Rage::Internal
64
64
  name_segments.join(":")
65
65
  end
66
66
 
67
+ LOCK_FILE_SUFFIX = rand(0x100000000).to_s(36)
68
+
67
69
  # Pick a worker process to execute a block of code.
68
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.
69
71
  # @yield The block of code to be executed by the picked worker
70
- def pick_a_worker(&block)
71
- @lock_file, lock_path = Tempfile.new.yield_self { |file| [file, file.path] }
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}")
72
75
 
73
- Iodine.on_state(:on_start) do
74
- worker_lock = File.new(lock_path)
76
+ lock_file = File.open(lock_path, File::CREAT | File::WRONLY)
75
77
 
76
- if worker_lock.flock(File::LOCK_EX | File::LOCK_NB)
77
- @worker_lock = worker_lock
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
78
81
  block.call
79
82
  end
80
83
  end
84
+
85
+ Iodine.running? ? attempt.call : Iodine.on_state(:on_start) { attempt.call }
81
86
  end
82
87
 
83
88
  private
84
89
 
90
+ def worker_locks
91
+ @worker_locks ||= []
92
+ end
93
+
85
94
  def dynamic_name_seed
86
95
  @dynamic_name_seed ||= ("a".."j").to_a.permutation
87
96
  end
@@ -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)
@@ -137,6 +137,10 @@ class Rage::OpenAPI::Converter
137
137
  end
138
138
  end
139
139
 
140
+ if (dynamic_schemas = @nodes.schema_registry).any?
141
+ (@spec["components"]["schemas"] ||= {}).merge!(dynamic_schemas)
142
+ end
143
+
140
144
  @spec["tags"] = @used_tags.sort.map { |tag| { "name" => tag } }
141
145
 
142
146
  @spec
@@ -177,7 +181,7 @@ class Rage::OpenAPI::Converter
177
181
  @used_security_schemes << auth_entry.merge(name: auth_name)
178
182
  end
179
183
 
180
- { auth_name => [] }
184
+ { auth_name => node.auth_scopes.fetch(auth_name, []) }
181
185
  end
182
186
  end
183
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,13 +120,44 @@ 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]
127
127
  end
128
128
  end
129
129
 
130
+ # @private
131
+ # @return [Array<Boolean, String, Hash>] a tuple of (is_collection, serializer, args)
132
+ def self.__parse_serializer_args(str)
133
+ is_collection, inner = __try_parse_collection(str)
134
+
135
+ if is_collection
136
+ # discard is_collection since we already know this is a collection from the outer call
137
+ _, clean_inner, args = __parse_serializer_args(inner)
138
+ if args.any?
139
+ [is_collection, clean_inner, args]
140
+ else
141
+ [is_collection, clean_inner, {}]
142
+ end
143
+ elsif str =~ /^([\w:]+)\(([^)]+)\)$/
144
+ [is_collection, $1, __parse_keywords($2)]
145
+ else
146
+ [is_collection, str, {}]
147
+ end
148
+ end
149
+
150
+ # @private
151
+ def self.__parse_keywords(str)
152
+ return {} if str.nil? || str.empty?
153
+
154
+ str.split(",").each_with_object({}) do |part, hash|
155
+ option = YAML.load(part)
156
+ return nil unless option.is_a?(Hash)
157
+ hash.merge!(option.transform_keys!(&:to_sym))
158
+ end
159
+ end
160
+
130
161
  # @private
131
162
  def self.__module_parent(klass)
132
163
  klass.name =~ /::[^:]+\z/ ? Object.const_get($`) : Object
@@ -191,6 +222,7 @@ require_relative "nodes/root"
191
222
  require_relative "nodes/parent"
192
223
  require_relative "nodes/method"
193
224
  require_relative "parsers/ext/alba"
225
+ require_relative "parsers/ext/blueprinter"
194
226
  require_relative "parsers/ext/active_record"
195
227
  require_relative "parsers/yaml"
196
228
  require_relative "parsers/shared_reference"
@@ -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
 
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Parsers::Ext::Blueprinter
4
+ def initialize(namespace: Object, root: Rage::OpenAPI::Nodes::Root.new, **)
5
+ @namespace = namespace
6
+ @root = root
7
+ end
8
+
9
+ def known_definition?(str)
10
+ _, str, _ = Rage::OpenAPI.__parse_serializer_args(str)
11
+ defined?(Blueprinter::Base) && @namespace.const_get(str).ancestors.include?(Blueprinter::Base)
12
+ rescue NameError
13
+ false
14
+ end
15
+
16
+ def parse(klass_str)
17
+ visitor = __parse(klass_str)
18
+ visitor.build_schema
19
+ end
20
+
21
+ def __parse(klass_str)
22
+ is_collection, klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str)
23
+
24
+ klass = @namespace.const_get(klass_str)
25
+ source_path, _ = Object.const_source_location(klass.name)
26
+ ast = Prism.parse_file(source_path)
27
+
28
+ visitor = Visitor.new(self, is_collection)
29
+ ast.value.accept(visitor)
30
+
31
+ visitor
32
+ end
33
+
34
+ class VisitorContext
35
+ attr_accessor :symbols, :keywords, :strings
36
+
37
+ def initialize
38
+ @symbols = []
39
+ @strings = []
40
+ @keywords = {}
41
+ end
42
+ end
43
+
44
+ class Visitor < Prism::Visitor
45
+ attr_accessor :schema
46
+
47
+ def initialize(parser, is_collection)
48
+ @parser = parser
49
+ @is_collection = is_collection
50
+
51
+ @context = nil
52
+ @schema = {}
53
+ @segment = @schema
54
+ @identifier = {}
55
+ end
56
+
57
+ def build_schema
58
+ result = { "type" => "object" }
59
+
60
+ properties = {}
61
+ properties.merge!(@identifier)
62
+ properties.merge!(@schema.sort.to_h)
63
+
64
+ result["properties"] = properties if properties.any?
65
+ result = { "type" => "array", "items" => result } if @is_collection
66
+ result
67
+ end
68
+
69
+ def visit_call_node(node)
70
+ case node.name
71
+ when :identifier
72
+ context = with_context { visit(node.arguments) }
73
+ @identifier[context.symbols.first] = { "type" => "string" }
74
+
75
+ when :fields, :field
76
+ context = with_context { visit(node.arguments) }
77
+
78
+ if context.keywords["name"]
79
+ @segment[context.keywords["name"]] = { "type" => "string" }
80
+ elsif node.block
81
+ @segment[context.symbols.first] = { "type" => "string" } if context.symbols.first
82
+ @segment[context.strings.first] = { "type" => "string" } if context.strings.first
83
+ else
84
+ context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } }
85
+ context.strings.each { |string| @segment[string] = { "type" => "string" } }
86
+ end
87
+ end
88
+ end
89
+
90
+ def visit_assoc_node(node)
91
+ @context.keywords[node.key.value] = node.value.unescaped
92
+ end
93
+
94
+ def visit_symbol_node(node)
95
+ @context.symbols << node.value
96
+ end
97
+
98
+ def visit_string_node(node)
99
+ @context.strings << node.unescaped
100
+ end
101
+
102
+ private
103
+
104
+ def with_context
105
+ @context = VisitorContext.new
106
+ yield
107
+ @context
108
+ end
109
+ end
110
+ end
@@ -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
 
@@ -5,12 +5,13 @@ class Rage::OpenAPI::Parsers::Response
5
5
  Rage::OpenAPI::Parsers::SharedReference,
6
6
  Rage::OpenAPI::Parsers::Ext::ActiveRecord,
7
7
  Rage::OpenAPI::Parsers::Ext::Alba,
8
+ Rage::OpenAPI::Parsers::Ext::Blueprinter,
8
9
  Rage::OpenAPI::Parsers::YAML
9
10
  ]
10
11
 
11
- def self.parse(response_tag, namespace:)
12
+ def self.parse(response_tag, namespace:, root:)
12
13
  parser = AVAILABLE_PARSERS.find do |parser_class|
13
- parser = parser_class.new(namespace:)
14
+ parser = parser_class.new(namespace:, root:)
14
15
  break parser if parser.known_definition?(response_tag)
15
16
  end
16
17
 
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OpenAPI::Parsers::YAML
4
+ # @private
5
+ class OptionalParam < String
6
+ end
7
+
4
8
  def initialize(**)
5
9
  end
6
10
 
7
11
  def known_definition?(yaml)
8
- object = YAML.safe_load(yaml) rescue nil
12
+ object = process_yaml(yaml) rescue nil
9
13
  !!object && object.is_a?(Enumerable)
10
14
  end
11
15
 
12
16
  def parse(yaml)
13
- __parse(YAML.safe_load(yaml))
17
+ __parse(process_yaml(yaml))
14
18
  end
15
19
 
16
20
  private
@@ -22,6 +26,8 @@ class Rage::OpenAPI::Parsers::YAML
22
26
  spec = { "type" => "object", "properties" => {} }
23
27
 
24
28
  object.each do |key, value|
29
+ key = OptionalParam.new(key[0...-1]) if key.end_with?("?")
30
+
25
31
  spec["properties"][key] = if value.is_a?(Enumerable)
26
32
  __parse(value)
27
33
  else
@@ -29,6 +35,8 @@ class Rage::OpenAPI::Parsers::YAML
29
35
  end
30
36
  end
31
37
 
38
+ spec["required"] = spec["properties"].keys.select { |k| !k.is_a?(OptionalParam) }
39
+
32
40
  elsif object.is_a?(Array) && object.length == 1
33
41
  spec = { "type" => "array", "items" => object[0].is_a?(Enumerable) ? __parse(object[0]) : type_to_spec(object[0]) }
34
42
 
@@ -39,9 +47,23 @@ class Rage::OpenAPI::Parsers::YAML
39
47
  spec
40
48
  end
41
49
 
42
- private
43
-
44
50
  def type_to_spec(type)
45
- Rage::OpenAPI.__type_to_spec(type) || { "type" => "string", "enum" => [type] }
51
+ is_collection, type_str = if type.is_a?(String)
52
+ Rage::OpenAPI.__try_parse_collection(type)
53
+ else
54
+ [false, type]
55
+ end
56
+
57
+ spec = Rage::OpenAPI.__type_to_spec(type_str) || { "type" => "string", "enum" => [type_str] }
58
+
59
+ if is_collection
60
+ { "type" => "array", "items" => spec }
61
+ else
62
+ spec
63
+ end
64
+ end
65
+
66
+ def process_yaml(str)
67
+ YAML.safe_load(str.gsub(/Array<([^>]+)>/, '[\1]'))
46
68
  end
47
69
  end