rage-rb 1.11.0 → 1.13.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: d2f73c4770587ebabc7d8fa0c4793d53742196b928f21cc362799cb068849982
4
+ data.tar.gz: aecaa4ae2848bc1b15a38da29d3030d718d74697faaf8525e60e82d10b24396c
5
5
  SHA512:
6
- metadata.gz: 8490a009689d8dbd9c98f47b01701e80dda65fe96dfa991540adc59dc0dd68859690263a15a6cb3f2f2ad23aa300c8bcf4eb6aed468a69b9997191af15806bae
7
- data.tar.gz: 7b5d887f78d0d102a9ea92027fe531d61870bed70828a56a4b79c51540c560553dd907c7c27a72867b4adbc241e4e1a4a529b84db991786d6845b457c229bfb2
6
+ metadata.gz: bb31ef275be5dc22322af8c9d563befc000ad2d15bb22454f2d3664736e6e7881ccdac365e6687fc123c23da1a06ee3cd4d63d08b8b5123c1fee67ebb97fbc57
7
+ data.tar.gz: 7debadefde85efa180f73e963b252cb1065a6b6f1bf4b8ca6e64e19d82a0ed838d3b321ddf9222006204ab59c3d90213e1a4eb6e1ff04d1d45572c1e166b3d77
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.13.0] - 2025-02-12
4
+
5
+ ### Added
6
+
7
+ - [CLI] Support the PORT ENV variable by [@TheBlackArroVV](https://github.com/TheBlackArroVV) (#124).
8
+ - Add the `RequestId` middleware (#127).
9
+
10
+ ### Fixed
11
+
12
+ - Correctly process persistent HTTP connections (#128).
13
+ - [OpenAPI] Ignore empty comments (#126).
14
+ - [Cable] Improve the time to connect (#129).
15
+
16
+ ## [1.12.0] - 2025-01-21
17
+
18
+ ### Added
19
+
20
+ - Add Redis adapter (#114).
21
+ - Add global response tags (#110).
22
+ - Implement around_action callbacks (#107).
23
+
24
+ ### Fixed
25
+
26
+ - Support date types in Alba serializers (#112).
27
+
3
28
  ## [1.11.0] - 2024-12-18
4
29
 
5
30
  ### Added
data/Gemfile CHANGED
@@ -15,9 +15,11 @@ group :test do
15
15
  gem "http"
16
16
  gem "pg"
17
17
  gem "mysql2"
18
+ gem "bigdecimal"
18
19
  gem "connection_pool", "~> 2.0"
19
20
  gem "rbnacl"
20
21
  gem "domain_name"
21
22
  gem "websocket-client-simple"
22
23
  gem "prism"
24
+ gem "redis-client"
23
25
  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
 
data/lib/rage/all.rb CHANGED
@@ -30,6 +30,7 @@ require_relative "middleware/origin_validator"
30
30
  require_relative "middleware/fiber_wrapper"
31
31
  require_relative "middleware/cors"
32
32
  require_relative "middleware/reloader"
33
+ require_relative "middleware/request_id"
33
34
 
34
35
  if defined?(Sidekiq)
35
36
  require_relative "sidekiq_session"
@@ -7,7 +7,7 @@ class Rage::Application
7
7
  end
8
8
 
9
9
  def call(env)
10
- init_logger
10
+ init_logger(env)
11
11
 
12
12
  handler = @router.lookup(env)
13
13
 
@@ -33,9 +33,9 @@ class Rage::Application
33
33
  DEFAULT_LOG_CONTEXT = {}.freeze
34
34
  private_constant :DEFAULT_LOG_CONTEXT
35
35
 
36
- def init_logger
36
+ def init_logger(env)
37
37
  Thread.current[:rage_logger] = {
38
- tags: [Iodine::Rack::Utils.gen_request_tag],
38
+ tags: [(env["rage.request_id"] ||= Iodine::Rack::Utils.gen_request_tag)],
39
39
  context: DEFAULT_LOG_CONTEXT,
40
40
  request_start: Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
41
  }
@@ -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,128 @@
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
+ REDIS_MIN_VERSION_SUPPORTED = Gem::Version.create(6)
18
+
19
+ def initialize(config)
20
+ @redis_stream = if (prefix = config.delete(:channel_prefix))
21
+ "#{prefix}:#{REDIS_STREAM_NAME}"
22
+ else
23
+ REDIS_STREAM_NAME
24
+ end
25
+
26
+ @redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config))
27
+ @server_uuid = SecureRandom.uuid
28
+
29
+ redis_version = get_redis_version
30
+ if redis_version < REDIS_MIN_VERSION_SUPPORTED
31
+ raise "Redis adapter only supports Redis 6+. Detected Redis version: #{redis_version}."
32
+ end
33
+
34
+ @trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid
35
+
36
+ pick_a_worker { poll }
37
+ end
38
+
39
+ def publish(stream_name, data)
40
+ message_uuid = SecureRandom.uuid
41
+
42
+ publish_redis.call(
43
+ "XADD",
44
+ @redis_stream,
45
+ trimming_method, "~", trimming_value,
46
+ "*",
47
+ "1", stream_name,
48
+ "2", data.to_json,
49
+ "3", @server_uuid,
50
+ "4", message_uuid
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def publish_redis
57
+ @publish_redis ||= @redis_config.new_client
58
+ end
59
+
60
+ def trimming_method
61
+ @trimming_strategy == :maxlen ? "MAXLEN" : "MINID"
62
+ end
63
+
64
+ def trimming_value
65
+ @trimming_strategy == :maxlen ? "10000" : ((Time.now.to_f - 5 * 60) * 1000).to_i
66
+ end
67
+
68
+ def get_redis_version
69
+ service_redis = @redis_config.new_client
70
+ version = service_redis.call("INFO").match(/redis_version:([[:graph:]]+)/)[1]
71
+
72
+ Gem::Version.create(version)
73
+
74
+ rescue RedisClient::Error => e
75
+ puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server."
76
+ puts e.backtrace.join("\n")
77
+ REDIS_MIN_VERSION_SUPPORTED
78
+
79
+ ensure
80
+ service_redis.close
81
+ end
82
+
83
+ def error_backoff_intervals
84
+ @error_backoff_intervals ||= Enumerator.new do |y|
85
+ y << 0.2 << 0.5 << 1 << 2 << 5
86
+ loop { y << 10 }
87
+ end
88
+ end
89
+
90
+ def poll
91
+ unless Fiber.scheduler
92
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
93
+ end
94
+
95
+ Iodine.on_state(:start_shutdown) do
96
+ @stopping = true
97
+ end
98
+
99
+ Fiber.schedule do
100
+ read_redis = @redis_config.new_client
101
+ last_id = (Time.now.to_f * 1000).to_i
102
+ last_message_uuid = nil
103
+
104
+ loop do
105
+ data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id)
106
+
107
+ if data
108
+ data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, broadcaster_uuid, _, message_uuid)|
109
+ if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid
110
+ Rage.config.cable.protocol.broadcast(stream_name, JSON.parse(serialized_data))
111
+ end
112
+
113
+ last_id = id
114
+ last_message_uuid = message_uuid
115
+ end
116
+ end
117
+
118
+ rescue RedisClient::Error => e
119
+ Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
120
+ sleep error_backoff_intervals.next
121
+ rescue => e
122
+ @stopping ? break : raise(e)
123
+ else
124
+ error_backoff_intervals.rewind
125
+ end
126
+ end
127
+ end
128
+ 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
@@ -42,33 +51,22 @@ module Rage::Cable
42
51
  end
43
52
 
44
53
  @protocol = protocol
54
+ @default_log_context = {}.freeze
45
55
  end
46
56
 
47
57
  def on_open(connection)
48
- Fiber.schedule do
49
- @protocol.on_open(connection)
50
- rescue => e
51
- log_error(e)
52
- end
58
+ connection.env["rage.request_id"] ||= Iodine::Rack::Utils.gen_request_tag
59
+ schedule_fiber(connection) { @protocol.on_open(connection) }
53
60
  end
54
61
 
55
62
  def on_message(connection, data)
56
- Fiber.schedule do
57
- @protocol.on_message(connection, data)
58
- rescue => e
59
- log_error(e)
60
- end
63
+ schedule_fiber(connection) { @protocol.on_message(connection, data) }
61
64
  end
62
65
 
63
66
  if protocol.respond_to?(:on_close)
64
67
  def on_close(connection)
65
68
  return unless ::Iodine.running?
66
-
67
- Fiber.schedule do
68
- @protocol.on_close(connection)
69
- rescue => e
70
- log_error(e)
71
- end
69
+ schedule_fiber(connection) { @protocol.on_close(connection) }
72
70
  end
73
71
  end
74
72
 
@@ -82,6 +80,15 @@ module Rage::Cable
82
80
 
83
81
  private
84
82
 
83
+ def schedule_fiber(connection)
84
+ Fiber.schedule do
85
+ Thread.current[:rage_logger] = { tags: [connection.env["rage.request_id"]], context: @default_log_context }
86
+ yield
87
+ rescue => e
88
+ log_error(e)
89
+ end
90
+ end
91
+
85
92
  def log_error(e)
86
93
  Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
87
94
  end
@@ -94,10 +101,14 @@ module Rage::Cable
94
101
  #
95
102
  # @param stream [String] the name of the stream
96
103
  # @param data [Object] the object to send to the clients. This will later be encoded according to the protocol used.
104
+ # @return [true]
97
105
  # @example
98
106
  # Rage.cable.broadcast("chat", { message: "A new member has joined!" })
99
107
  def self.broadcast(stream, data)
100
- Rage.config.cable.protocol.broadcast(stream, data)
108
+ __protocol.broadcast(stream, data)
109
+ __adapter&.publish(stream, data)
110
+
111
+ true
101
112
  end
102
113
 
103
114
  # @!parse [ruby]
@@ -120,6 +131,11 @@ module Rage::Cable
120
131
  # end
121
132
  # end
122
133
 
134
+ module Adapters
135
+ autoload :Base, "rage/cable/adapters/base"
136
+ autoload :Redis, "rage/cable/adapters/redis"
137
+ end
138
+
123
139
  module Protocol
124
140
  end
125
141
  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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zlib"
4
+ require "set"
5
+
3
6
  ##
4
7
  # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
5
8
  # The class that defines a protocol should respond to the following methods:
@@ -17,6 +20,9 @@
17
20
  # * `on_shutdown`
18
21
  # * `on_close`
19
22
  #
23
+ # It is likely that all logic around `@subscription_identifiers` has nothing to do with the protocol itself and
24
+ # should be extracted into another class. We'll refactor this once we start working on a new protocol.
25
+ #
20
26
  class Rage::Cable::Protocol::ActioncableV1Json
21
27
  module TYPE
22
28
  WELCOME = "welcome"
@@ -55,14 +61,30 @@ class Rage::Cable::Protocol::ActioncableV1Json
55
61
  def self.init(router)
56
62
  @router = router
57
63
 
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)
64
+ Iodine.on_state(:on_start) do
65
+ ping_counter = Time.now.to_i
66
+
67
+ Iodine.run_every(3000) do
68
+ ping_counter += 1
69
+ Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json, Iodine::PubSub::PROCESS)
70
+ end
62
71
  end
63
72
 
64
- # Hash<String(stream name) => Array<Hash>(subscription params)>
65
- @subscription_identifiers = Hash.new { |hash, key| hash[key] = [] }
73
+ # Hash<String(stream name) => Set<Hash>(subscription params)>
74
+ @subscription_identifiers = Hash.new { |hash, key| hash[key] = Set.new }
75
+
76
+ # this is a fallback to synchronize subscription identifiers across different worker processes;
77
+ # we expect connections to be distributed among all workers, so this code will almost never be called;
78
+ # we also synchronize subscriptions with the master process so that the forks that are spun up instead
79
+ # of the crashed ones also had access to the identifiers;
80
+ Iodine.subscribe("cable:synchronize") do |_, subscription_msg|
81
+ stream_name, params = Rage::ParamsParser.json_parse(subscription_msg)
82
+ @subscription_identifiers[stream_name] << params
83
+ end
84
+
85
+ Iodine.on_state(:on_finish) do
86
+ Iodine.unsubscribe("cable:synchronize")
87
+ end
66
88
  end
67
89
 
68
90
  # The method is called any time a new WebSocket connection is established.
@@ -147,8 +169,12 @@ class Rage::Cable::Protocol::ActioncableV1Json
147
169
  # @param name [String] the stream name
148
170
  # @param params [Hash] parameters associated with the client
149
171
  def self.subscribe(connection, name, params)
150
- connection.subscribe("cable:#{name}:#{params.hash}")
151
- @subscription_identifiers[name] << params unless @subscription_identifiers[name].include?(params)
172
+ connection.subscribe("cable:#{name}:#{Zlib.crc32(params.to_s)}")
173
+
174
+ unless @subscription_identifiers[name].include?(params)
175
+ @subscription_identifiers[name] << params
176
+ ::Iodine.publish("cable:synchronize", [name, params].to_json)
177
+ end
152
178
  end
153
179
 
154
180
  # Broadcast data to all clients connected to a stream.
@@ -156,12 +182,8 @@ class Rage::Cable::Protocol::ActioncableV1Json
156
182
  # @param name [String] the stream name
157
183
  # @param data [Object] the data to send
158
184
  def self.broadcast(name, data)
159
- i, identifiers = 0, @subscription_identifiers[name]
160
-
161
- while i < identifiers.length
162
- params = identifiers[i]
163
- ::Iodine.publish("cable:#{name}:#{params.hash}", serialize(params, data))
164
- i += 1
185
+ @subscription_identifiers[name].each do |params|
186
+ ::Iodine.publish("cable:#{name}:#{Zlib.crc32(params.to_s)}", serialize(params, data))
165
187
  end
166
188
  end
167
189
  end
data/lib/rage/cli.rb CHANGED
@@ -71,7 +71,7 @@ module Rage
71
71
 
72
72
  server_options = { service: :http, handler: app }
73
73
 
74
- server_options[:port] = options[:port] || Rage.config.server.port
74
+ server_options[:port] = options[:port] || ENV["PORT"] || Rage.config.server.port
75
75
  server_options[:address] = options[:binding] || (Rage.env.production? ? "0.0.0.0" : "localhost")
76
76
  server_options[:timeout] = Rage.config.server.timeout
77
77
  server_options[:max_clients] = Rage.config.server.max_clients
@@ -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
182
+
183
+ # @private
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
155
189
 
156
190
  # @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)
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
@@ -509,27 +585,27 @@ class RageController::API
509
585
 
510
586
  if !defined?(::ActionController::Parameters)
511
587
  # Get the request data. The keys inside the hash are symbols, so `params.keys` returns an array of `Symbol`.<br>
512
- # You can also load Strong Params to have Rage automatically wrap `params` in an instance of `ActionController::Parameters`.<br>
588
+ # You can also load Strong Parameters to have Rage automatically wrap `params` in an instance of `ActionController::Parameters`.<br>
513
589
  # At the same time, if you are not implementing complex filtering rules or working with nested structures, consider using native `Hash#fetch` and `Hash#slice` instead.
514
590
  #
515
591
  # For multipart file uploads, the uploaded files are represented by an instance of {Rage::UploadedFile}.
516
592
  #
517
593
  # @return [Hash{Symbol=>String,Array,Hash,Numeric,NilClass,TrueClass,FalseClass}]
518
- # @example
519
- # # make sure to load strong params before the `require "rage/all"` call
520
- # require "active_support/all"
521
- # require "action_controller/metal/strong_parameters"
594
+ # @example With Strong Parameters
595
+ # # in the Gemfile:
596
+ # gem "activesupport", require: "active_support/all"
597
+ # gem "actionpack", require: "action_controller/metal/strong_parameters"
522
598
  #
523
- # params.permit(:user).require(:full_name, :dob)
524
- # @example
525
- # # without strong params
599
+ # # in the controller:
600
+ # params.require(:user).permit(:full_name, :dob)
601
+ # @example Without Strong Parameters
526
602
  # params.fetch(:user).slice(:full_name, :dob)
527
603
  def params
528
604
  @__params
529
605
  end
530
606
  else
531
607
  def params
532
- @params ||= ActionController::Parameters.new(@__params)
608
+ @__params__ ||= ActionController::Parameters.new(@__params)
533
609
  end
534
610
  end
535
611
 
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.
@@ -12,10 +12,10 @@ class Rage::FiberScheduler
12
12
 
13
13
  def io_wait(io, events, timeout = nil)
14
14
  f = Fiber.current
15
- ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { |err| f.resume(err) }
15
+ ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) { |err| f.resume(err) }
16
16
 
17
17
  err = Fiber.defer(io.fileno)
18
- if err && err < 0
18
+ if err == false || (err && err < 0)
19
19
  err
20
20
  else
21
21
  events
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The middleware establishes a connection between the `X-Request-Id` header (typically generated by a firewall, load balancer, or web server) and
5
+ # Rage's internal logging system. It ensures that:
6
+ #
7
+ # 1. All logs produced during the request are tagged with the value submitted in the `X-Request-Id` header.
8
+ # 2. The request ID is added back to the response in the `X-Request-Id` header. If no `X-Request-Id` header was provided in the request, the middleware adds an internally generated ID to the response.
9
+ #
10
+ # Additionally, the `X-Request-Id` header value is sanitized to a maximum of 255 characters, allowing only alphanumeric characters and dashes.
11
+ #
12
+ # @example
13
+ # Rage.configure do
14
+ # config.middleware.use Rage::RequestId
15
+ # end
16
+ #
17
+ class Rage::RequestId
18
+ BLACKLISTED_CHARACTERS = /[^\w\-@]/
19
+
20
+ def initialize(app)
21
+ @app = app
22
+ end
23
+
24
+ def call(env)
25
+ env["rage.request_id"] = validate_external_request_id(env["HTTP_X_REQUEST_ID"])
26
+ response = @app.call(env)
27
+ response[1]["X-Request-Id"] = env["rage.request_id"]
28
+
29
+ response
30
+ end
31
+
32
+ private
33
+
34
+ def validate_external_request_id(request_id)
35
+ if request_id && !request_id.empty?
36
+ request_id = request_id[0...255] if request_id.size > 255
37
+ request_id = request_id.gsub(BLACKLISTED_CHARACTERS, "") if request_id =~ BLACKLISTED_CHARACTERS
38
+
39
+ request_id
40
+ end
41
+ end
42
+ end
@@ -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)
@@ -119,7 +119,7 @@ module Rage::OpenAPI
119
119
 
120
120
  # @private
121
121
  def self.__log_warn(log)
122
- puts "WARNING: #{log}"
122
+ puts "[OpenAPI] WARNING: #{log}"
123
123
  end
124
124
 
125
125
  module Nodes
@@ -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
 
@@ -14,7 +16,7 @@ class Rage::OpenAPI::Parser
14
16
  else
15
17
  node.deprecated = true
16
18
  end
17
- children = find_children(comments[i + 1..])
19
+ children = find_children(comments[i + 1..], node)
18
20
 
19
21
  elsif expression =~ /@private\b/
20
22
  if node.private
@@ -22,7 +24,7 @@ class Rage::OpenAPI::Parser
22
24
  else
23
25
  node.private = true
24
26
  end
25
- children = find_children(comments[i + 1..])
27
+ children = find_children(comments[i + 1..], node)
26
28
 
27
29
  elsif expression =~ /@version\s/
28
30
  if node.root.version
@@ -38,9 +40,12 @@ 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
- children = find_children(comments[i + 1..])
48
+ children = find_children(comments[i + 1..], node)
44
49
 
45
50
  if tail_name
46
51
  Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
@@ -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
 
@@ -76,7 +83,9 @@ class Rage::OpenAPI::Parser
76
83
  children = nil
77
84
  expression = comments[i].slice.delete_prefix("#").strip
78
85
 
79
- if !expression.start_with?("@")
86
+ if expression.empty?
87
+ # no-op
88
+ elsif !expression.start_with?("@")
80
89
  if node.summary
81
90
  Rage::OpenAPI.__log_warn "invalid summary entry detected at #{location_msg(comments[i])}; summary should only be one line"
82
91
  else
@@ -89,7 +98,7 @@ class Rage::OpenAPI::Parser
89
98
  else
90
99
  node.deprecated = true
91
100
  end
92
- children = find_children(comments[i + 1..])
101
+ children = find_children(comments[i + 1..], node)
93
102
 
94
103
  elsif expression =~ /@private\b/
95
104
  if node.parents.any?(&:private)
@@ -97,38 +106,14 @@ class Rage::OpenAPI::Parser
97
106
  else
98
107
  node.private = true
99
108
  end
100
- children = find_children(comments[i + 1..])
109
+ children = find_children(comments[i + 1..], node)
101
110
 
102
111
  elsif expression =~ /@description\s/
103
- children = find_children(comments[i + 1..])
112
+ children = find_children(comments[i + 1..], node)
104
113
  node.description = [expression[13..]] + children
105
114
 
106
115
  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
116
+ parse_response_tag(expression, node, comments[i])
132
117
 
133
118
  elsif expression =~ /@request\s/
134
119
  request = expression[9..]
@@ -149,7 +134,7 @@ class Rage::OpenAPI::Parser
149
134
 
150
135
  elsif expression =~ /@internal\b/
151
136
  # no-op
152
- children = find_children(comments[i + 1..])
137
+ children = find_children(comments[i + 1..], node)
153
138
 
154
139
  else
155
140
  Rage::OpenAPI.__log_warn "unrecognized `#{expression.split(" ")[0]}` tag detected at #{location_msg(comments[i])}"
@@ -165,16 +150,21 @@ class Rage::OpenAPI::Parser
165
150
 
166
151
  private
167
152
 
168
- def find_children(comments)
153
+ def find_children(comments, node)
169
154
  children = []
170
155
 
171
156
  comments.each do |comment|
172
- expression = comment.slice.sub(/^#\s/, "")
157
+ expression = comment.slice.sub(/^#\s?/, "")
173
158
 
174
- if expression.start_with?(/\s{2}/)
159
+ if expression.empty?
160
+ # no-op
161
+ elsif expression.start_with?(/\s{2}/)
175
162
  children << expression.strip
176
163
  elsif expression.start_with?("@")
177
164
  break
165
+ elsif !node.summary
166
+ # no-op - this is likely the summary entry
167
+ break
178
168
  else
179
169
  Rage::OpenAPI.__log_warn "unrecognized expression detected at #{location_msg(comment)}; use two spaces to mark multi-line expressions"
180
170
  break
@@ -190,4 +180,32 @@ class Rage::OpenAPI::Parser
190
180
 
191
181
  "#{relative_path}:#{location.start_line}"
192
182
  end
183
+
184
+ def parse_response_tag(expression, node, comment)
185
+ response = expression[10..].strip
186
+ status, response_data = if response =~ /^\d{3}$/
187
+ [response, nil]
188
+ elsif response =~ /^\d{3}/
189
+ response.split(" ", 2)
190
+ else
191
+ ["200", response]
192
+ end
193
+
194
+ if node.responses.has_key?(status)
195
+ Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comment)}"
196
+ elsif response_data.nil?
197
+ node.responses[status] = nil
198
+ else
199
+ parsed = Rage::OpenAPI::Parsers::Response.parse(
200
+ response_data,
201
+ namespace: Rage::OpenAPI.__module_parent(node.controller)
202
+ )
203
+
204
+ if parsed
205
+ node.responses[status] = parsed
206
+ else
207
+ Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comment)}"
208
+ end
209
+ end
210
+ end
193
211
  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/request.rb CHANGED
@@ -76,6 +76,14 @@ class Rage::Request
76
76
  @env["HTTP_USER_AGENT"]
77
77
  end
78
78
 
79
+ # Returns the unique request ID. By default, this ID is internally generated, and all log entries created during the request
80
+ # are tagged with it. Alternatively, you can use the {Rage::RequestId} middleware to derive the ID from the `X-Request-Id` header.
81
+ def request_id
82
+ @env["rage.request_id"]
83
+ end
84
+
85
+ alias_method :uuid, :request_id
86
+
79
87
  private
80
88
 
81
89
  def if_none_match
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "rage-rb", "~> <%= Rage::VERSION[0..2] %>"
3
+ gem "rage-rb", "~> <%= Rage::VERSION.match(/\d+.\d+/).to_s %>"
4
4
 
5
5
  # Build JSON APIs with ease
6
6
  # gem "alba"
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.13.0"
5
5
  end
data/rage.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency "thor", "~> 1.0"
31
31
  spec.add_dependency "rack", "~> 2.0"
32
- spec.add_dependency "rage-iodine", "~> 4.0"
32
+ spec.add_dependency "rage-iodine", "~> 4.1"
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
34
  spec.add_dependency "rack-test", "~> 2.1"
35
35
  spec.add_dependency "rake", ">= 12.0"
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.13.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-02-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '4.0'
47
+ version: '4.1'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '4.0'
54
+ version: '4.1'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: zeitwerk
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -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
@@ -139,6 +141,7 @@ files:
139
141
  - lib/rage/middleware/fiber_wrapper.rb
140
142
  - lib/rage/middleware/origin_validator.rb
141
143
  - lib/rage/middleware/reloader.rb
144
+ - lib/rage/middleware/request_id.rb
142
145
  - lib/rage/openapi/builder.rb
143
146
  - lib/rage/openapi/collector.rb
144
147
  - lib/rage/openapi/converter.rb