rage-rb 1.22.1 → 1.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c18be377e162e14f77e7709fc573ff35cb9965796f9451af4e05baac5da095ca
4
- data.tar.gz: 9d6193d3a72322f0d9edb702f512975451cac38d32a7505b943065222aa45115
3
+ metadata.gz: bbc12eab5d13d5570107f5c7bf6d0b68f6f652eccc1eaafefdb47ef92ed3b264
4
+ data.tar.gz: 7adca2c0e96d38ca69c32f4fc12b03984b6aa46578d48d084645e11891915e99
5
5
  SHA512:
6
- metadata.gz: 1ceffe055ffce47aca8970ee09de970665532ff4551afcbd0bf5efb9e55a380762e0b135f889b92be1acd6ba77c3d77d9025616e3d99ee75a6bfb4ccec5fbd4e
7
- data.tar.gz: d9052e5e4c78aaca8425b2b7ae60622d275273ff85b18304db0657007103845c720465e744e73b077a180ae00813959c30288f174b798ec26e9518aec9062da0
6
+ metadata.gz: cb010618b1afebee5baa5fee44963d8cdf4f1543769b79ee81d26e9a0815c3ea59d6b0cf4dad2f53adabbe8d26ae0688876d2a0f690e1497173469446ba4a9e8
7
+ data.tar.gz: ee292c1c8b93fed32dbe015e6de898df1f0bb63ede69deb2b1eb41cddee1e339ade19ed8981682697721b02c3923bb8782358616c5d7c264cc0d465887b6e79b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.23.0] - 2026-04-15
4
+
5
+ ### Fixed
6
+ - [SSE] Ensure connection is closed for single-value SSE streams by [@jsxs0](https://github.com/jsxs0) (#264).
7
+ - Ensure task ID seed is always greater than timestamps in existing WAL files by [@Abishekcs](https://github.com/Abishekcs) (#255)
8
+ - Correctly load routes in Rails apps (#249).
9
+ - [SSE] Ensure connection is closed when raw SSE stream raises by [@jsxs0](https://github.com/jsxs0) (#248).
10
+ - [OpenAPI] Alba parser: silent fallback for unresolvable association resources by [@pratyush07-hub](https://github.com/pratyush07-hub) (#258).
11
+ - Fix `Rage::UploadedFile#close` (#262).
12
+
13
+ ### Added
14
+ - [SSE] Add tests for log context propagation across fiber boundaries by [@jsxs0](https://github.com/jsxs0) (#267).
15
+ - Add singular `resource` routing with plural controller mapping and document the helper by [@anuj-pal27](https://github.com/anuj-pal27) (#247).
16
+ - [SSE] Add support for unbounded streams (#266).
17
+ - [OpenAPI] Support OpenAPI generation for file parameters by [@Digvijay-x1](https://github.com/Digvijay-x1) (#229).
18
+ - [Deferred] Add configurable retry options by [@Digvijay-x1](https://github.com/Digvijay-x1) (#225).
19
+ - [SSE] Add unit tests for `SSE::ConnectionProxy` by [@jsxs0](https://github.com/jsxs0) (#245).
20
+ - Custom renderer support by [@anuj-pal27](https://github.com/anuj-pal27) (#244).
21
+ - [SSE] Add graceful shutdown support for SSE streams by [@tmchow](https://github.com/tmchow) (#261).
22
+
3
23
  ## [1.22.1] - 2026-04-01
4
24
 
5
25
  ### Fixed
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/rage-rb.svg)](https://badge.fury.io/rb/rage-rb)
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
- ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.2%2B-%23f40000)
7
+ ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.3%2B-%23f40000)
8
8
 
9
9
  **Rage** is an API-first Ruby framework with a modern, fiber-based runtime that enables transparent, non-blocking concurrency while preserving familiar developer ergonomics. It focuses on **capability and operational simplicity**, letting teams build production-grade systems in a single, coherent runtime.
10
10
 
data/lib/rage/all.rb CHANGED
@@ -36,3 +36,4 @@ require_relative "middleware/request_id"
36
36
  require_relative "middleware/body_finalizer"
37
37
 
38
38
  require_relative "telemetry/telemetry"
39
+ require_relative "sse/sse"
@@ -139,6 +139,52 @@ class Rage::Configuration
139
139
  def after_initialize(&block)
140
140
  push_hook(block, :after_initialize)
141
141
  end
142
+
143
+ # Register a custom renderer that generates overloads `render` on all controllers.
144
+ # The block receives the object passed to `render` together with any additional keyword arguments.
145
+ # The code inside the block is executed in the context of the controller instance, so you can access all usual controller methods in it.
146
+ # The return value of the block is used as the response body.
147
+ #
148
+ # @param name [Symbol, String] the name of the renderer
149
+ # @param block [Proc] the rendering logic. The block is executed in the controller's context and its return value becomes the response body
150
+ # @raise [ArgumentError] if no block is given or if a renderer with the same name is already registered
151
+ #
152
+ # @example Register an ERB renderer
153
+ # Rage.configure do
154
+ # config.renderer(:erb) do |path, trim_mode: nil|
155
+ # headers["content-type"] = "text/html"
156
+ # template = File.read("app/views/#{path}.html.erb")
157
+ #
158
+ # ERB.new(template, trim_mode:).result(binding)
159
+ # end
160
+ # end
161
+ # @example Use in a controller
162
+ # class ReportsController < RageController::API
163
+ # def index
164
+ # render erb: "reports/index"
165
+ # end
166
+ # end
167
+ # @example Pass arguments
168
+ # class ReportsController < RageController::API
169
+ # def index
170
+ # render erb: "reports/index", trim_mode: "%<>"
171
+ # end
172
+ # end
173
+ # @example Set response status
174
+ # class ReportsController < RageController::API
175
+ # def index
176
+ # render erb: "reports/index", status: 202
177
+ # end
178
+ # end
179
+ def renderer(name, &block)
180
+ @renderers ||= {}
181
+ raise ArgumentError, "renderer requires a block" unless block_given?
182
+ name = name.to_sym
183
+ if @renderers.key?(name)
184
+ raise ArgumentError, "a renderer named :#{name} is already registered"
185
+ end
186
+ @renderers[name] = RendererEntry.new(block)
187
+ end
142
188
  # @!endgroup
143
189
 
144
190
  # @!group Middleware Configuration
@@ -219,6 +265,11 @@ class Rage::Configuration
219
265
  end
220
266
  # @!endgroup
221
267
 
268
+ # @private
269
+ def pubsub
270
+ @pubsub ||= PubSub.new
271
+ end
272
+
222
273
  # @private
223
274
  def internal
224
275
  @internal ||= Internal.new
@@ -937,6 +988,37 @@ class Rage::Configuration
937
988
  attr_accessor :key
938
989
  end
939
990
 
991
+ # @private
992
+ class PubSub
993
+ attr_reader :adapter
994
+
995
+ def initialize
996
+ @adapter = if config.any?
997
+ case config[:adapter]
998
+ when "redis"
999
+ Rage::PubSub::Adapters::Redis.new(adapter_config)
1000
+ end
1001
+ end
1002
+ end
1003
+
1004
+ def config
1005
+ @config ||= begin
1006
+ config_file = Rage.root.join("config/pubsub.yml")
1007
+
1008
+ config = if config_file.exist?
1009
+ yaml = ERB.new(config_file.read).result
1010
+ YAML.safe_load(yaml, aliases: true, symbolize_names: true)&.dig(Rage.env.to_sym)
1011
+ end
1012
+
1013
+ config || {}
1014
+ end
1015
+ end
1016
+
1017
+ def adapter_config
1018
+ config.except(:adapter)
1019
+ end
1020
+ end
1021
+
940
1022
  # @private
941
1023
  class Internal
942
1024
  attr_accessor :rails_mode
@@ -999,7 +1081,32 @@ class Rage::Configuration
999
1081
  end
1000
1082
 
1001
1083
  Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry
1084
+
1085
+ __define_custom_renderers if @renderers
1086
+ end
1087
+
1088
+ # @private
1089
+ class RendererEntry
1090
+ attr_reader :block
1091
+
1092
+ def initialize(block)
1093
+ @block = block
1094
+ @applied = false
1095
+ end
1096
+
1097
+ def applied? = @applied
1098
+ def applied! = (@applied = true)
1099
+ end
1100
+ private_constant :RendererEntry
1101
+
1102
+ def __define_custom_renderers
1103
+ @renderers.each do |name, entry|
1104
+ next if entry.applied?
1105
+ RageController::API.__register_renderer(name, entry.block)
1106
+ entry.applied!
1107
+ end
1002
1108
  end
1109
+ private :__define_custom_renderers
1003
1110
  end
1004
1111
 
1005
1112
  # @!parse [ruby]
@@ -211,6 +211,12 @@ class RageController::API
211
211
  RUBY
212
212
  end
213
213
 
214
+ # @private
215
+ def __register_renderer(name, block)
216
+ prepend(RageController::Renderers) unless ancestors.include?(RageController::Renderers)
217
+ RageController::Renderers.__register(name, block)
218
+ end
219
+
214
220
  ############
215
221
  #
216
222
  # PUBLIC API
@@ -445,11 +451,14 @@ class RageController::API
445
451
  end
446
452
  end # class << self
447
453
 
454
+ DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8"
455
+ private_constant :DEFAULT_CONTENT_TYPE
456
+
448
457
  # @private
449
458
  def initialize(env, params)
450
459
  @__env = env
451
460
  @__params = params
452
- @__status, @__headers, @__body = 204, { "content-type" => "application/json; charset=utf-8" }, []
461
+ @__status, @__headers, @__body = 204, { "content-type" => DEFAULT_CONTENT_TYPE }, []
453
462
  @__rendered = false
454
463
  end
455
464
 
@@ -480,10 +489,10 @@ class RageController::API
480
489
  @session ||= Rage::Session.new(cookies)
481
490
  end
482
491
 
483
- # Send a response to the client.
492
+ # Send a response to the client. Keywords corresponding to custom renderers (see {Rage::Configuration#renderer}) will be delegated automatically.
484
493
  #
485
- # @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
486
- # @param plain [String] send a text response to the client
494
+ # @param json [String, #to_json] send a json response to the client; objects will be serialized automatically
495
+ # @param plain [#to_s] send a text response to the client
487
496
  # @param sse [#each, Proc, #to_json] send an SSE response to the client
488
497
  # @param status [Integer, Symbol] set a response status
489
498
  # @example Render a JSON object
@@ -508,7 +517,8 @@ class RageController::API
508
517
  @__body << if json
509
518
  json.is_a?(String) ? json : json.to_json
510
519
  else
511
- @__headers["content-type"] = "text/plain; charset=utf-8"
520
+ ct = @__headers["content-type"]
521
+ @__headers["content-type"] = "text/plain; charset=utf-8" if ct.nil? || ct == DEFAULT_CONTENT_TYPE
512
522
  plain.to_s
513
523
  end
514
524
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # This module overloads the `render` method on {RageController::API RageController::API} to enable the usage of custom renderers defined using {Rage::Configuration#renderer}.
5
+ #
6
+ module RageController::Renderers
7
+ # @private
8
+ def self.prepended(_)
9
+ @__renderers = {}
10
+ end
11
+
12
+ # @private
13
+ # rubocop:disable Layout/IndentationWidth, Layout/EndAlignment, Layout/HeredocIndentation
14
+ def self.__register(name, block)
15
+ @__renderers[name] = Rage::Internal.define_dynamic_method(self, block)
16
+
17
+ render_args = @__renderers.keys.map { |key| "#{key}: nil" }.join(", ")
18
+
19
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
20
+ def render(#{render_args}, status: nil, **)
21
+ raise "Render was called multiple times in this action." if @__rendered
22
+
23
+ active_renderers = []
24
+ #{@__renderers.keys.map { |key| "active_renderers << :#{key} if #{key}" }.join("\n")}
25
+
26
+ return super(status:, **) if active_renderers.empty?
27
+
28
+ if active_renderers.size > 1
29
+ raise Rage::Errors::AmbiguousRenderError, "Only one renderer can be used per 'render' call, but multiple were provided: \#{active_renderers.join(", ")}"
30
+ end
31
+
32
+ result = case active_renderers.first
33
+ #{@__renderers.map do |renderer_name, method_name|
34
+ <<~RUBY
35
+ when :#{renderer_name}
36
+ #{method_name}(#{renderer_name}, **)
37
+ RUBY
38
+ end.join("\n")}
39
+ end
40
+
41
+ return if @__rendered
42
+ render plain: result.to_s, status: (status || 200)
43
+ end
44
+ RUBY
45
+ end
46
+ # rubocop:enable all
47
+ end
@@ -44,8 +44,24 @@ class Rage::Deferred::Backends::Disk
44
44
  @recovered_storages = storage_files[1..] if storage_files.length > 1
45
45
  end
46
46
 
47
- # create seed value for the task IDs
48
- task_id_seed = Time.now.to_i # TODO: ensure timestamps in the file are not higher
47
+ # include recovered storages from crashed/previous workers
48
+ all_storages = [@storage, *@recovered_storages].compact
49
+
50
+ # find the highest task timestamp across all storage files
51
+ storage_file_max_timestamp = all_storages.map do |storage|
52
+ max_timestamp = 0
53
+ storage.tap(&:rewind).each_line(chomp: true) do |entry|
54
+ next unless entry[9...12] == "add"
55
+ timestamp = entry[13..].split("-").first.to_i
56
+ max_timestamp = timestamp if timestamp > max_timestamp
57
+ end
58
+ max_timestamp
59
+ end.max.to_i
60
+
61
+ # apply Lamport IR2(b) From time, clocks and the ordering of
62
+ # events in a distributed system to guard against clock skew
63
+ task_id_seed = [Time.now.to_i, storage_file_max_timestamp].max + 1
64
+
49
65
  @task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
50
66
  Iodine.run_every(1_000) do
51
67
  task_id_seed += 1
@@ -117,7 +133,7 @@ class Rage::Deferred::Backends::Disk
117
133
  # `@recovered_storages` will only be present if the server has previously crashed and left
118
134
  # some storage files behind, or if the new cluster is started with fewer workers than before;
119
135
  # TLDR: this code is expected to execute very rarely
120
- @recovered_storages.each { |storage| recover_tasks(storage) }
136
+ @recovered_storages.each { |storage| recover_tasks(storage.tap(&:rewind)) }
121
137
  end
122
138
 
123
139
  tasks = {}
@@ -27,7 +27,7 @@ class Rage::Deferred::Metadata
27
27
  # @return [Boolean] `true` if a failure will schedule another attempt, `false` otherwise
28
28
  def will_retry?
29
29
  task = Rage::Deferred::Context.get_task(context)
30
- task.__should_retry?(attempts)
30
+ !!task.__next_retry_in(attempts, nil)
31
31
  end
32
32
 
33
33
  private
@@ -38,14 +38,15 @@ class Rage::Deferred::Queue
38
38
  Fiber.schedule do
39
39
  Iodine.task_inc!
40
40
 
41
- is_completed = task.new.__perform(context)
41
+ result = task.new.__perform(context)
42
42
 
43
- if is_completed
43
+ if result == true
44
44
  @backend.remove(task_id)
45
45
  else
46
46
  attempts = Rage::Deferred::Context.inc_attempts(context)
47
- if task.__should_retry?(attempts)
48
- enqueue(context, delay: task.__next_retry_in(attempts), task_id:)
47
+ retry_in = task.__next_retry_in(attempts, result)
48
+ if retry_in
49
+ enqueue(context, delay: retry_in, task_id:)
49
50
  else
50
51
  @backend.remove(task_id)
51
52
  end
@@ -85,7 +85,7 @@ module Rage::Deferred::Task
85
85
  Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
86
86
  end
87
87
  end
88
- false
88
+ e
89
89
  end
90
90
 
91
91
  private def restore_log_info(context)
@@ -105,6 +105,62 @@ module Rage::Deferred::Task
105
105
  end
106
106
 
107
107
  module ClassMethods
108
+ # Set the maximum number of retry attempts for this task.
109
+ #
110
+ # @param count [Integer] the maximum number of retry attempts
111
+ # @example
112
+ # class SendWelcomeEmail
113
+ # include Rage::Deferred::Task
114
+ # max_retries 10
115
+ #
116
+ # def perform(email)
117
+ # # ...
118
+ # end
119
+ # end
120
+ def max_retries(count)
121
+ value = Integer(count)
122
+
123
+ if value.negative?
124
+ raise ArgumentError, "max_retries should be a valid non-negative integer"
125
+ end
126
+
127
+ @__max_retries = value
128
+ rescue ArgumentError, TypeError
129
+ raise ArgumentError, "max_retries should be a valid non-negative integer"
130
+ end
131
+
132
+ # Override this method to customize retry behavior per exception.
133
+ #
134
+ # Return an Integer to retry in that many seconds.
135
+ # Return `super` to use the default exponential backoff.
136
+ # Return `false` or `nil` to abort retries.
137
+ #
138
+ # @param exception [Exception] the exception that caused the failure
139
+ # @param attempt [Integer] the current attempt number (1-indexed)
140
+ # @return [Integer, false, nil] the retry interval in seconds, or false/nil to abort
141
+ # @example
142
+ # class ProcessPayment
143
+ # include Rage::Deferred::Task
144
+ #
145
+ # def self.retry_interval(exception, attempt:)
146
+ # case exception
147
+ # when TemporaryNetworkError
148
+ # 10 # Retry in 10 seconds
149
+ # when InvalidDataError
150
+ # false # Do not retry
151
+ # else
152
+ # super # Default backoff strategy
153
+ # end
154
+ # end
155
+ #
156
+ # def perform(payment_id)
157
+ # # ...
158
+ # end
159
+ # end
160
+ def retry_interval(exception, attempt:)
161
+ __default_backoff(attempt)
162
+ end
163
+
108
164
  def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
109
165
  context = Rage::Deferred::Context.build(self, args, kwargs)
110
166
 
@@ -118,13 +174,24 @@ module Rage::Deferred::Task
118
174
  end
119
175
 
120
176
  # @private
121
- def __should_retry?(attempts)
122
- attempts < MAX_ATTEMPTS
177
+ def __next_retry_in(attempts, exception)
178
+ max = @__max_retries || MAX_ATTEMPTS
179
+ return if attempts > max
180
+
181
+ interval = retry_interval(exception, attempt: attempts)
182
+ return if !interval
183
+
184
+ unless interval.is_a?(Numeric)
185
+ Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff")
186
+ return __default_backoff(attempts)
187
+ end
188
+
189
+ interval
123
190
  end
124
191
 
125
192
  # @private
126
- def __next_retry_in(attempts)
127
- rand(BACKOFF_INTERVAL * 2**attempts.to_i) + 1
193
+ def __default_backoff(attempt)
194
+ rand(BACKOFF_INTERVAL * 2**attempt) + 1
128
195
  end
129
196
  end
130
197
  end
data/lib/rage/errors.rb CHANGED
@@ -10,4 +10,7 @@ module Rage::Errors
10
10
 
11
11
  class InvalidCustomProxy < StandardError
12
12
  end
13
+
14
+ class AmbiguousRenderError < StandardError
15
+ end
13
16
  end
data/lib/rage/internal.rb CHANGED
@@ -44,6 +44,42 @@ 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
+ # Pick a worker process to execute a block of code.
68
+ # 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
+ # @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
+
73
+ Iodine.on_state(:on_start) do
74
+ worker_lock = File.new(lock_path)
75
+
76
+ if worker_lock.flock(File::LOCK_EX | File::LOCK_NB)
77
+ @worker_lock = worker_lock
78
+ block.call
79
+ end
80
+ end
81
+ end
82
+
47
83
  private
48
84
 
49
85
  def dynamic_name_seed
@@ -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" }
@@ -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
@@ -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}"
@@ -151,12 +151,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
151
151
  with_inner_segment(key, is_array:) { visit(node.block) }
152
152
  else
153
153
  resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(association.to_s)}Resource")
154
- is_valid_resource = @parser.namespace.const_get(resource) rescue false
154
+ resolved = Rage::OpenAPI.__resolve_resource(resource, @parser.namespace)
155
155
 
156
- @segment[key] = if is_array
157
- @parser.__parse_nested(is_valid_resource ? "[#{resource}]" : "[Rage]") # TODO
156
+ @segment[key] = if resolved
157
+ is_array ? @parser.__parse_nested("[#{resource}]") : @parser.__parse_nested(resource)
158
158
  else
159
- @parser.__parse_nested(is_valid_resource ? resource : "Rage")
159
+ base = { "type" => "object" }
160
+ is_array ? { "type" => "array", "items" => base } : base
160
161
  end
161
162
  end
162
163