rage-rb 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04ca7e51d4117d534058db889d82d6ce86af9c9edf389a03711baffa3a8bc484
4
- data.tar.gz: 347fa6296d6fa65d99125146b7f82921a9ae2b99836d6f0191fc31dc135896c7
3
+ metadata.gz: 8c42516bec6fabfb0b06d9f11a5156298474a48f7988edba746122ae4ec172dc
4
+ data.tar.gz: 3b6b5b7692bb693ba53137d11bc1d7883ace69e496b5a99ec367da9a78ec4675
5
5
  SHA512:
6
- metadata.gz: 8490a009689d8dbd9c98f47b01701e80dda65fe96dfa991540adc59dc0dd68859690263a15a6cb3f2f2ad23aa300c8bcf4eb6aed468a69b9997191af15806bae
7
- data.tar.gz: 7b5d887f78d0d102a9ea92027fe531d61870bed70828a56a4b79c51540c560553dd907c7c27a72867b4adbc241e4e1a4a529b84db991786d6845b457c229bfb2
6
+ metadata.gz: b72d88f12c3608006637d822c7d3f955ef549e61ee644fc3d7748f507381cfe4917d3eea7f6cb75cf49f053f7ba34ce407a8ed06033a06e89e95208a15b6144e
7
+ data.tar.gz: f60d1950bce78665acfe6af71aeff1694692b6063cbf76e6c39a0af5dbcf8a79672b8603c0a39ce5d2b0beb0ecf61edfba310d6140fb94b899d85dbad0dd2c21
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.12.0] - 2025-01-21
4
+
5
+ ### Added
6
+
7
+ - Add Redis adapter (#114).
8
+ - Add global response tags (#110).
9
+ - Implement around_action callbacks (#107).
10
+
11
+ ### Fixed
12
+
13
+ - Support date types in Alba serializers (#112).
14
+
3
15
  ## [1.11.0] - 2024-12-18
4
16
 
5
17
  ### Added
data/Gemfile CHANGED
@@ -20,4 +20,5 @@ group :test do
20
20
  gem "domain_name"
21
21
  gem "websocket-client-simple"
22
22
  gem "prism"
23
+ gem "redis-client"
23
24
  end
data/README.md CHANGED
@@ -6,8 +6,7 @@
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
7
  ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.1%2B-%23f40000)
8
8
 
9
-
10
- Inspired by [Deno](https://deno.com) and built on top of [Iodine](https://github.com/rage-rb/iodine), this is a Ruby web framework that is based on the following design principles:
9
+ Rage is a high-performance framework compatible with Rails, featuring [WebSocket](https://github.com/rage-rb/rage/wiki/WebSockets-guide) support and automatic generation of [OpenAPI](https://github.com/rage-rb/rage/wiki/OpenAPI-Guide) documentation for your APIs. The framework is built on top of [Iodine](https://github.com/rage-rb/iodine) and is based on the following design principles:
11
10
 
12
11
  * **Rails compatible API** - Rails' API is clean, straightforward, and simply makes sense. It was one of the reasons why Rails was so successful in the past.
13
12
 
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Cable::Adapters::Base
4
+ def pick_a_worker(&block)
5
+ _lock, lock_path = Tempfile.new.yield_self { |file| [file, file.path] }
6
+
7
+ Iodine.on_state(:on_start) do
8
+ if File.new(lock_path).flock(File::LOCK_EX | File::LOCK_NB)
9
+ if Rage.logger.debug?
10
+ puts "INFO: #{Process.pid} is managing #{self.class.name.split("::").last} subscriptions."
11
+ end
12
+ block.call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ if !defined?(RedisClient)
6
+ fail <<~ERR
7
+
8
+ Redis adapter depends on the `redis-client` gem. Add the following line to your Gemfile:
9
+ gem "redis-client"
10
+
11
+ ERR
12
+ end
13
+
14
+ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
15
+ REDIS_STREAM_NAME = "rage:cable:messages"
16
+ DEFAULT_REDIS_OPTIONS = { reconnect_attempts: [0.05, 0.1, 0.5] }
17
+
18
+ def initialize(config)
19
+ @redis_stream = if (prefix = config.delete(:channel_prefix))
20
+ "#{prefix}:#{REDIS_STREAM_NAME}"
21
+ else
22
+ REDIS_STREAM_NAME
23
+ end
24
+
25
+ @redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config))
26
+ @server_uuid = SecureRandom.uuid
27
+
28
+ redis_version = get_redis_version
29
+ if redis_version < Gem::Version.create(5)
30
+ raise "Redis adapter only supports Redis 5+. Detected Redis version: #{redis_version}."
31
+ end
32
+
33
+ @trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid
34
+
35
+ pick_a_worker { poll }
36
+ end
37
+
38
+ def publish(stream_name, data)
39
+ message_uuid = SecureRandom.uuid
40
+
41
+ publish_redis.call(
42
+ "XADD",
43
+ @redis_stream,
44
+ trimming_method, "~", trimming_value,
45
+ "*",
46
+ "1", stream_name,
47
+ "2", data.to_json,
48
+ "3", @server_uuid,
49
+ "4", message_uuid
50
+ )
51
+ end
52
+
53
+ private
54
+
55
+ def publish_redis
56
+ @publish_redis ||= @redis_config.new_client
57
+ end
58
+
59
+ def trimming_method
60
+ @trimming_strategy == :maxlen ? "MAXLEN" : "MINID"
61
+ end
62
+
63
+ def trimming_value
64
+ @trimming_strategy == :maxlen ? "10000" : ((Time.now.to_f - 5 * 60) * 1000).to_i
65
+ end
66
+
67
+ def get_redis_version
68
+ service_redis = @redis_config.new_client
69
+ version = service_redis.call("INFO").match(/redis_version:([[:graph:]]+)/)[1]
70
+
71
+ Gem::Version.create(version)
72
+
73
+ rescue RedisClient::Error => e
74
+ puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server."
75
+ puts e.backtrace.join("\n")
76
+ Gem::Version.create(5)
77
+
78
+ ensure
79
+ service_redis.close
80
+ end
81
+
82
+ def error_backoff_intervals
83
+ @error_backoff_intervals ||= Enumerator.new do |y|
84
+ y << 0.2 << 0.5 << 1 << 2 << 5
85
+ loop { y << 10 }
86
+ end
87
+ end
88
+
89
+ def poll
90
+ unless Fiber.scheduler
91
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
92
+ end
93
+
94
+ Iodine.on_state(:start_shutdown) do
95
+ @stopping = true
96
+ end
97
+
98
+ Fiber.schedule do
99
+ read_redis = @redis_config.new_client
100
+ last_id = (Time.now.to_f * 1000).to_i
101
+ last_message_uuid = nil
102
+
103
+ loop do
104
+ data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id)
105
+
106
+ if data
107
+ data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, broadcaster_uuid, _, message_uuid)|
108
+ if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid
109
+ Rage.config.cable.protocol.broadcast(stream_name, JSON.parse(serialized_data))
110
+ end
111
+
112
+ last_id = id
113
+ last_message_uuid = message_uuid
114
+ end
115
+ end
116
+
117
+ rescue RedisClient::Error => e
118
+ Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
119
+ sleep error_backoff_intervals.next
120
+ rescue => e
121
+ @stopping ? break : raise(e)
122
+ else
123
+ error_backoff_intervals.rewind
124
+ end
125
+ end
126
+ end
127
+ end
@@ -8,11 +8,11 @@ module Rage::Cable
8
8
  # run Rage.cable.application
9
9
  # end
10
10
  def self.application
11
- protocol = Rage.config.cable.protocol
12
- protocol.init(__router)
11
+ # explicitly initialize the adapter
12
+ __adapter
13
13
 
14
- handler = __build_handler(protocol)
15
- accept_response = [0, protocol.protocol_definition, []]
14
+ handler = __build_handler(__protocol)
15
+ accept_response = [0, __protocol.protocol_definition, []]
16
16
 
17
17
  application = ->(env) do
18
18
  if env["rack.upgrade?"] == :websocket
@@ -31,6 +31,15 @@ module Rage::Cable
31
31
  @__router ||= Router.new
32
32
  end
33
33
 
34
+ # @private
35
+ def self.__protocol
36
+ @__protocol ||= Rage.config.cable.protocol.tap { |protocol| protocol.init(__router) }
37
+ end
38
+
39
+ def self.__adapter
40
+ @__adapter ||= Rage.config.cable.adapter
41
+ end
42
+
34
43
  # @private
35
44
  def self.__build_handler(protocol)
36
45
  klass = Class.new do
@@ -94,10 +103,14 @@ module Rage::Cable
94
103
  #
95
104
  # @param stream [String] the name of the stream
96
105
  # @param data [Object] the object to send to the clients. This will later be encoded according to the protocol used.
106
+ # @return [true]
97
107
  # @example
98
108
  # Rage.cable.broadcast("chat", { message: "A new member has joined!" })
99
109
  def self.broadcast(stream, data)
100
- Rage.config.cable.protocol.broadcast(stream, data)
110
+ __protocol.broadcast(stream, data)
111
+ __adapter&.publish(stream, data)
112
+
113
+ true
101
114
  end
102
115
 
103
116
  # @!parse [ruby]
@@ -120,6 +133,11 @@ module Rage::Cable
120
133
  # end
121
134
  # end
122
135
 
136
+ module Adapters
137
+ autoload :Base, "rage/cable/adapters/base"
138
+ autoload :Redis, "rage/cable/adapters/redis"
139
+ end
140
+
123
141
  module Protocol
124
142
  end
125
143
  end
@@ -418,7 +418,7 @@ class Rage::Cable::Channel
418
418
  # broadcast("notifications", { message: "A new member has joined!" })
419
419
  # end
420
420
  def broadcast(stream, data)
421
- Rage.config.cable.protocol.broadcast(stream, data)
421
+ Rage.cable.broadcast(stream, data)
422
422
  end
423
423
 
424
424
  # Transmit data to the current client.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zlib"
4
+
3
5
  ##
4
6
  # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
5
7
  # The class that defines a protocol should respond to the following methods:
@@ -17,6 +19,9 @@
17
19
  # * `on_shutdown`
18
20
  # * `on_close`
19
21
  #
22
+ # It is likely that all logic around `@subscription_identifiers` has nothing to do with the protocol itself and
23
+ # should be extracted into another class. We'll refactor this once we start working on a new protocol.
24
+ #
20
25
  class Rage::Cable::Protocol::ActioncableV1Json
21
26
  module TYPE
22
27
  WELCOME = "welcome"
@@ -55,14 +60,30 @@ class Rage::Cable::Protocol::ActioncableV1Json
55
60
  def self.init(router)
56
61
  @router = router
57
62
 
58
- ping_counter = Time.now.to_i
59
- ::Iodine.run_every(3000) do
60
- ping_counter += 1
61
- ::Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json)
63
+ Iodine.on_state(:on_start) do
64
+ ping_counter = Time.now.to_i
65
+
66
+ Iodine.run_every(3000) do
67
+ ping_counter += 1
68
+ Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json, Iodine::PubSub::PROCESS)
69
+ end
62
70
  end
63
71
 
64
72
  # Hash<String(stream name) => Array<Hash>(subscription params)>
65
73
  @subscription_identifiers = Hash.new { |hash, key| hash[key] = [] }
74
+
75
+ # this is a fallback to synchronize subscription identifiers across different worker processes;
76
+ # we expect connections to be distributed among all workers, so this code will almost never be called;
77
+ # we also synchronize subscriptions with the master process so that the forks that are spun up instead
78
+ # of the crashed ones also had access to the identifiers;
79
+ Iodine.subscribe("cable:synchronize") do |_, subscription_msg|
80
+ stream_name, params = Rage::ParamsParser.json_parse(subscription_msg)
81
+ @subscription_identifiers[stream_name] << params unless @subscription_identifiers[stream_name].include?(params)
82
+ end
83
+
84
+ Iodine.on_state(:on_finish) do
85
+ Iodine.unsubscribe("cable:synchronize")
86
+ end
66
87
  end
67
88
 
68
89
  # The method is called any time a new WebSocket connection is established.
@@ -147,8 +168,12 @@ class Rage::Cable::Protocol::ActioncableV1Json
147
168
  # @param name [String] the stream name
148
169
  # @param params [Hash] parameters associated with the client
149
170
  def self.subscribe(connection, name, params)
150
- connection.subscribe("cable:#{name}:#{params.hash}")
151
- @subscription_identifiers[name] << params unless @subscription_identifiers[name].include?(params)
171
+ connection.subscribe("cable:#{name}:#{Zlib.crc32(params.to_s)}")
172
+
173
+ unless @subscription_identifiers[name].include?(params)
174
+ @subscription_identifiers[name] << params
175
+ ::Iodine.publish("cable:synchronize", [name, params].to_json)
176
+ end
152
177
  end
153
178
 
154
179
  # Broadcast data to all clients connected to a stream.
@@ -160,7 +185,7 @@ class Rage::Cable::Protocol::ActioncableV1Json
160
185
 
161
186
  while i < identifiers.length
162
187
  params = identifiers[i]
163
- ::Iodine.publish("cable:#{name}:#{params.hash}", serialize(params, data))
188
+ ::Iodine.publish("cable:#{name}:#{Zlib.crc32(params.to_s)}", serialize(params, data))
164
189
  i += 1
165
190
  end
166
191
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require "erb"
5
+
3
6
  ##
4
7
  # `Rage.configure` can be used to adjust the behavior of your Rage application:
5
8
  #
@@ -277,6 +280,30 @@ class Rage::Configuration
277
280
  end
278
281
  end
279
282
  end
283
+
284
+ def config
285
+ @config ||= begin
286
+ config_file = Rage.root.join("config/cable.yml")
287
+
288
+ if config_file.exist?
289
+ yaml = ERB.new(config_file.read).result
290
+ YAML.safe_load(yaml, aliases: true, symbolize_names: true)[Rage.env.to_sym] || {}
291
+ else
292
+ {}
293
+ end
294
+ end
295
+ end
296
+
297
+ def adapter_config
298
+ config.except(:adapter)
299
+ end
300
+
301
+ def adapter
302
+ case config[:adapter]
303
+ when "redis"
304
+ Rage::Cable::Adapters::Redis.new(adapter_config)
305
+ end
306
+ end
280
307
  end
281
308
 
282
309
  class PublicFileServer
@@ -11,6 +11,8 @@ class RageController::API
11
11
  def __register_action(action)
12
12
  raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
13
13
 
14
+ around_actions_total = 0
15
+
14
16
  before_actions_chunk = if @__before_actions
15
17
  lines = __before_actions_for(action).map do |h|
16
18
  condition = if h[:if] && h[:unless]
@@ -21,10 +23,30 @@ class RageController::API
21
23
  "unless #{h[:unless]}"
22
24
  end
23
25
 
24
- <<~RUBY
25
- #{h[:name]} #{condition}
26
- return [@__status, @__headers, @__body] if @__rendered
27
- RUBY
26
+ if h[:around]
27
+ around_actions_total += 1
28
+
29
+ if condition
30
+ <<~RUBY
31
+ __should_apply_around_action = #{condition}
32
+ !@__before_callback_rendered
33
+ end
34
+ #{h[:wrapper]}(__should_apply_around_action) do
35
+ RUBY
36
+ else
37
+ <<~RUBY
38
+ __should_apply_around_action = !@__before_callback_rendered
39
+ #{h[:wrapper]}(__should_apply_around_action) do
40
+ RUBY
41
+ end
42
+ else
43
+ <<~RUBY
44
+ unless @__before_callback_rendered
45
+ #{h[:name]} #{condition}
46
+ @__before_callback_rendered = true if @__rendered
47
+ end
48
+ RUBY
49
+ end
28
50
  end
29
51
 
30
52
  lines.join("\n")
@@ -32,6 +54,8 @@ class RageController::API
32
54
  ""
33
55
  end
34
56
 
57
+ around_actions_end_chunk = around_actions_total.times.reduce("") { |memo| memo + "end\n" }
58
+
35
59
  after_actions_chunk = if @__after_actions
36
60
  lines = __after_actions_for(action).map do |h|
37
61
  condition = if h[:if] && h[:unless]
@@ -96,12 +120,15 @@ class RageController::API
96
120
 
97
121
  #{wrap_parameters_chunk}
98
122
  #{before_actions_chunk}
99
- #{action}
123
+ #{action} unless @__before_callback_rendered
124
+ #{around_actions_end_chunk}
100
125
 
101
126
  #{if !after_actions_chunk.empty?
102
127
  <<~RUBY
103
- @__rendered = true
104
- #{after_actions_chunk}
128
+ unless @__before_callback_rendered
129
+ @__rendered = true
130
+ #{after_actions_chunk}
131
+ end
105
132
  RUBY
106
133
  end}
107
134
 
@@ -151,13 +178,29 @@ class RageController::API
151
178
  end
152
179
 
153
180
  # @private
154
- @@__tmp_name_seed = ("a".."i").to_a.permutation
181
+ @@__dynamic_name_seed = ("a".."i").to_a.permutation
155
182
 
156
183
  # @private
157
- # define temporary method based on a block
158
- def define_tmp_method(block)
159
- name = @@__tmp_name_seed.next.join
160
- define_method("__rage_tmp_#{name}", block)
184
+ # define a method based on a block
185
+ def define_dynamic_method(block)
186
+ name = @@__dynamic_name_seed.next.join
187
+ define_method("__rage_dynamic_#{name}", block)
188
+ end
189
+
190
+ # @private
191
+ # define a method that will call a specified method if a condition is `true` or yield if `false`
192
+ def define_maybe_yield(method_name)
193
+ name = @@__dynamic_name_seed.next.join
194
+
195
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
196
+ def __rage_dynamic_#{name}(condition)
197
+ if condition
198
+ #{method_name} { yield }
199
+ else
200
+ yield
201
+ end
202
+ end
203
+ RUBY
161
204
  end
162
205
 
163
206
  ############
@@ -183,7 +226,7 @@ class RageController::API
183
226
  def rescue_from(*klasses, with: nil, &block)
184
227
  unless with
185
228
  if block_given?
186
- with = define_tmp_method(block)
229
+ with = define_dynamic_method(block)
187
230
  else
188
231
  raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
189
232
  end
@@ -239,6 +282,39 @@ class RageController::API
239
282
  end
240
283
  end
241
284
 
285
+ # Register a new `around_action` hook. Calls with the same `action_name` will overwrite the previous ones.
286
+ #
287
+ # @param action_name [Symbol, nil] the name of the callback to add
288
+ # @param [Hash] opts action options
289
+ # @option opts [Symbol, Array<Symbol>] :only restrict the callback to run only for specific actions
290
+ # @option opts [Symbol, Array<Symbol>] :except restrict the callback to run for all actions except specified
291
+ # @option opts [Symbol, Proc] :if only run the callback if the condition is true
292
+ # @option opts [Symbol, Proc] :unless only run the callback if the condition is false
293
+ # @example
294
+ # around_action :wrap_in_transaction
295
+ #
296
+ # def wrap_in_transaction
297
+ # ActiveRecord::Base.transaction do
298
+ # yield
299
+ # end
300
+ # end
301
+ def around_action(action_name = nil, **opts, &block)
302
+ action = prepare_action_params(action_name, **opts, &block)
303
+ action.merge!(around: true, wrapper: define_maybe_yield(action[:name]))
304
+
305
+ if @__before_actions && @__before_actions.frozen?
306
+ @__before_actions = @__before_actions.dup
307
+ end
308
+
309
+ if @__before_actions.nil?
310
+ @__before_actions = [action]
311
+ elsif (i = @__before_actions.find_index { |a| a[:name] == action_name })
312
+ @__before_actions[i] = action
313
+ else
314
+ @__before_actions << action
315
+ end
316
+ end
317
+
242
318
  # Register a new `after_action` hook. Calls with the same `action_name` will overwrite the previous ones.
243
319
  #
244
320
  # @param action_name [Symbol, nil] the name of the callback to add
@@ -273,7 +349,7 @@ class RageController::API
273
349
  # @example
274
350
  # skip_before_action :find_photo, only: :create
275
351
  def skip_before_action(action_name, only: nil, except: nil)
276
- i = @__before_actions&.find_index { |a| a[:name] == action_name }
352
+ i = @__before_actions&.find_index { |a| a[:name] == action_name && !a[:around] }
277
353
  raise ArgumentError, "The following action was specified to be skipped but couldn't be found: #{self}##{action_name}" unless i
278
354
 
279
355
  @__before_actions = @__before_actions.dup if @__before_actions.frozen?
@@ -339,7 +415,7 @@ class RageController::API
339
415
  # used by `before_action` and `after_action`
340
416
  def prepare_action_params(action_name = nil, **opts, &block)
341
417
  if block_given?
342
- action_name = define_tmp_method(block)
418
+ action_name = define_dynamic_method(block)
343
419
  elsif action_name.nil?
344
420
  raise ArgumentError, "No handler provided. Pass the `action_name` parameter or provide a block."
345
421
  end
@@ -354,8 +430,8 @@ class RageController::API
354
430
  unless: _unless
355
431
  }
356
432
 
357
- action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
358
- action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
433
+ action[:if] = define_dynamic_method(action[:if]) if action[:if].is_a?(Proc)
434
+ action[:unless] = define_dynamic_method(action[:unless]) if action[:unless].is_a?(Proc)
359
435
 
360
436
  action
361
437
  end
data/lib/rage/cookies.rb CHANGED
@@ -69,7 +69,7 @@ class Rage::Cookies
69
69
  # @example
70
70
  # cookies.permanent[:user_id] = current_user.id
71
71
  def permanent
72
- dup.tap { |c| c.expires = Time.now + 20 * 365 * 24 * 60 * 60 }
72
+ dup.tap { |c| c.expires = Date.today.next_year(20) }
73
73
  end
74
74
 
75
75
  # Set a cookie.
@@ -11,6 +11,7 @@ class Rage::OpenAPI::Builder
11
11
  class ParsingError < StandardError
12
12
  end
13
13
 
14
+ # @param namespace [String, Module]
14
15
  def initialize(namespace: nil)
15
16
  @namespace = namespace.to_s if namespace
16
17
 
@@ -5,6 +5,7 @@
5
5
  # At this point we don't care whether these are Rage OpenAPI comments or not.
6
6
  #
7
7
  class Rage::OpenAPI::Collector < Prism::Visitor
8
+ # @param comments [Array<Prism::InlineComment>]
8
9
  def initialize(comments)
9
10
  @comments = comments.dup
10
11
  @method_comments = {}
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OpenAPI::Converter
4
+ # @param nodes [Rage::OpenAPI::Nodes::Root]
4
5
  def initialize(nodes)
5
6
  @nodes = nodes
6
7
  @used_tags = Set.new
@@ -51,8 +52,10 @@ class Rage::OpenAPI::Converter
51
52
  "tags" => build_tags(node)
52
53
  }
53
54
 
54
- memo[path][method]["responses"] = if node.responses.any?
55
- node.responses.each_with_object({}) do |(status, response), memo|
55
+ responses = node.parents.reverse.map(&:responses).reduce(&:merge).merge(node.responses)
56
+
57
+ memo[path][method]["responses"] = if responses.any?
58
+ responses.each_with_object({}) do |(status, response), memo|
56
59
  memo[status] = if response.nil?
57
60
  { "description" => "" }
58
61
  elsif response.key?("$ref") && response["$ref"].start_with?("#/components/responses")
@@ -5,6 +5,9 @@ class Rage::OpenAPI::Nodes::Method
5
5
  attr_accessor :http_method, :http_path, :summary, :tag, :deprecated, :private, :description,
6
6
  :request, :responses, :parameters
7
7
 
8
+ # @param controller [RageController::API]
9
+ # @param action [String]
10
+ # @param parents [Array<Rage::OpenAPI::Nodes::Parent>]
8
11
  def initialize(controller, action, parents)
9
12
  @controller = controller
10
13
  @action = action
@@ -2,12 +2,15 @@
2
2
 
3
3
  class Rage::OpenAPI::Nodes::Parent
4
4
  attr_reader :root, :controller
5
- attr_accessor :deprecated, :private, :auth
5
+ attr_accessor :deprecated, :private, :auth, :responses
6
6
 
7
+ # @param root [Rage::OpenAPI::Nodes::Root]
8
+ # @param controller [RageController::API]
7
9
  def initialize(root, controller)
8
10
  @root = root
9
11
  @controller = controller
10
12
 
11
13
  @auth = []
14
+ @responses = {}
12
15
  end
13
16
  end
@@ -28,10 +28,15 @@ class Rage::OpenAPI::Nodes::Root
28
28
  @leaves = []
29
29
  end
30
30
 
31
+ # @return [Array<Rage::OpenAPI::Nodes::Parent>]
31
32
  def parent_nodes
32
33
  @parent_nodes_cache.values
33
34
  end
34
35
 
36
+ # @param controller [RageController::API]
37
+ # @param action [String]
38
+ # @param parent_nodes [Array<Rage::OpenAPI::Nodes::Parent>]
39
+ # @return [Rage::OpenAPI::Nodes::Method]
35
40
  def new_method_node(controller, action, parent_nodes)
36
41
  node = Rage::OpenAPI::Nodes::Method.new(controller, action, parent_nodes)
37
42
  @leaves << node
@@ -39,6 +44,8 @@ class Rage::OpenAPI::Nodes::Root
39
44
  node
40
45
  end
41
46
 
47
+ # @param controller [RageController::API]
48
+ # @return [Rage::OpenAPI::Nodes::Parent]
42
49
  def new_parent_node(controller)
43
50
  @parent_nodes_cache[controller] ||= begin
44
51
  node = Rage::OpenAPI::Nodes::Parent.new(self, controller)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OpenAPI::Parser
4
+ # @param node [Rage::OpenAPI::Nodes::Parent]
5
+ # @param comments [Array<Prism::InlineComment>]
4
6
  def parse_dangling_comments(node, comments)
5
7
  i = 0
6
8
 
@@ -38,6 +40,9 @@ class Rage::OpenAPI::Parser
38
40
  node.root.title = expression[7..]
39
41
  end
40
42
 
43
+ elsif expression =~ /@response\s/
44
+ parse_response_tag(expression, node, comments[i])
45
+
41
46
  elsif expression =~ /@auth\s/
42
47
  method, name, tail_name = expression[6..].split(" ", 3)
43
48
  children = find_children(comments[i + 1..])
@@ -69,6 +74,8 @@ class Rage::OpenAPI::Parser
69
74
  end
70
75
  end
71
76
 
77
+ # @param node [Rage::OpenAPI::Nodes::Method]
78
+ # @param comments [Array<Prism::InlineComment>]
72
79
  def parse_method_comments(node, comments)
73
80
  i = 0
74
81
 
@@ -104,31 +111,7 @@ class Rage::OpenAPI::Parser
104
111
  node.description = [expression[13..]] + children
105
112
 
106
113
  elsif expression =~ /@response\s/
107
- response = expression[10..].strip
108
- status, response_data = if response =~ /^\d{3}$/
109
- [response, nil]
110
- elsif response =~ /^\d{3}/
111
- response.split(" ", 2)
112
- else
113
- ["200", response]
114
- end
115
-
116
- if node.responses.has_key?(status)
117
- Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comments[i])}"
118
- elsif response_data.nil?
119
- node.responses[status] = nil
120
- else
121
- parsed = Rage::OpenAPI::Parsers::Response.parse(
122
- response_data,
123
- namespace: Rage::OpenAPI.__module_parent(node.controller)
124
- )
125
-
126
- if parsed
127
- node.responses[status] = parsed
128
- else
129
- Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comments[i])}"
130
- end
131
- end
114
+ parse_response_tag(expression, node, comments[i])
132
115
 
133
116
  elsif expression =~ /@request\s/
134
117
  request = expression[9..]
@@ -190,4 +173,32 @@ class Rage::OpenAPI::Parser
190
173
 
191
174
  "#{relative_path}:#{location.start_line}"
192
175
  end
176
+
177
+ def parse_response_tag(expression, node, comment)
178
+ response = expression[10..].strip
179
+ status, response_data = if response =~ /^\d{3}$/
180
+ [response, nil]
181
+ elsif response =~ /^\d{3}/
182
+ response.split(" ", 2)
183
+ else
184
+ ["200", response]
185
+ end
186
+
187
+ if node.responses.has_key?(status)
188
+ Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comment)}"
189
+ elsif response_data.nil?
190
+ node.responses[status] = nil
191
+ else
192
+ parsed = Rage::OpenAPI::Parsers::Response.parse(
193
+ response_data,
194
+ namespace: Rage::OpenAPI.__module_parent(node.controller)
195
+ )
196
+
197
+ if parsed
198
+ node.responses[status] = parsed
199
+ else
200
+ Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comment)}"
201
+ end
202
+ end
203
+ end
193
204
  end
@@ -273,6 +273,10 @@ class Rage::OpenAPI::Parsers::Ext::Alba
273
273
  { "type" => "number" }
274
274
  when "Float"
275
275
  { "type" => "number", "format" => "float" }
276
+ when "Date"
277
+ { "type" => "string", "format" => "date" }
278
+ when "DateTime", "Time"
279
+ { "type" => "string", "format" => "date-time" }
276
280
  else
277
281
  { "type" => "string" }
278
282
  end
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.11.0"
4
+ VERSION = "1.12.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-18 00:00:00.000000000 Z
11
+ date: 2025-01-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -116,6 +116,8 @@ files:
116
116
  - lib/rage.rb
117
117
  - lib/rage/all.rb
118
118
  - lib/rage/application.rb
119
+ - lib/rage/cable/adapters/base.rb
120
+ - lib/rage/cable/adapters/redis.rb
119
121
  - lib/rage/cable/cable.rb
120
122
  - lib/rage/cable/channel.rb
121
123
  - lib/rage/cable/connection.rb