rage-rb 1.2.2 → 1.4.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: 75beeb9e9f98cc51b8b7979be7bbd65253a319092855dfb6bfed0a23fca3bc13
4
- data.tar.gz: dc3d0676ca07217e325ec7af031848de7943d1655a3d27f8845500c5a92657b4
3
+ metadata.gz: 43393d351828980659e1a5e574887b818727744bf679bca2c1e0e0a164848496
4
+ data.tar.gz: d732652d4966c9cdcdd1825ec692b6c88286e467c229a2e4acdb7658e2b0a455
5
5
  SHA512:
6
- metadata.gz: 874cec69c0eb95b78f015105ad75695ad147a190b50488e8bb2bddf2bab5eb9811140d70b6566e3002729892a92ce78f568e48842d3c207ae2db7a3e76b7af80
7
- data.tar.gz: 848ad81e200d92fcbbeef7cf43f80cdac118bf41b559186e0976e59a79304fac20e183289a0d67316bc88b7d02dfaf98c4f832a1c6a034be63a2eb9582f637a4
6
+ metadata.gz: e23ca7a0309a24e665850c6c7798a3a4d7cb5dd5d8f239346907bc2be0ae3c6446530e5299e2f5a6b08f773281a8e3c570dc19b9ebe1f6cbe1b0712b643ed688
7
+ data.tar.gz: 9578c4f912c7ce713639acd0c043de9f4b757ff816e60dd5ae9637434699190d8c84af29a0a42fd7c0f0f32ae6724b710429cde6fab20011c23fd79bade36d13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.0] - 2024-05-01
4
+
5
+ ### Added
6
+
7
+ - Support cookies and sessions (#69).
8
+
9
+ ### Fixed
10
+
11
+ - Improve compatibility with ActiveRecord 7.1 (#80).
12
+
13
+ ## [1.3.0] - 2024-04-17
14
+
15
+ ### Added
16
+
17
+ - Introduce the `ActiveRecord::ConnectionPool` patch (#78).
18
+
3
19
  ## [1.2.2] - 2024-04-03
4
20
 
5
21
  ### Fixed
data/Gemfile CHANGED
@@ -14,3 +14,6 @@ gem "yard"
14
14
  gem "pg"
15
15
  gem "mysql2"
16
16
  gem "connection_pool", "~> 2.0"
17
+
18
+ gem "rbnacl"
19
+ gem "domain_name"
@@ -15,6 +15,14 @@
15
15
  #
16
16
  # > Defines the verbosity of the Rage logger. This option defaults to `:debug` for all environments except production, where it defaults to `:info`. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`.
17
17
  #
18
+ # • _config.secret_key_base_
19
+ #
20
+ # > The `secret_key_base` is used as the input secret to the application's key generator, which is used to encrypt cookies. Rage will fall back to the `SECRET_KEY_BASE` environment variable if this is not set.
21
+ #
22
+ # • _config.fallback_secret_key_base_
23
+ #
24
+ # > Defines one or several old secrets that need to be rotated. Can accept a single key or an array of keys. Rage will fall back to the `FALLBACK_SECRET_KEY_BASE` environment variable if this is not set.
25
+ #
18
26
  # # Middleware Configuration
19
27
  #
20
28
  # • _config.middleware.use_
@@ -85,9 +93,22 @@
85
93
  #
86
94
  # > Specifies connection timeout.
87
95
  #
96
+ # # Transient Settings
97
+ #
98
+ # The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
99
+ #
100
+ # • _RAGE_DISABLE_IO_WRITE_
101
+ #
102
+ # > Disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
103
+ #
104
+ # • _RAGE_PATCH_AR_POOL_
105
+ #
106
+ # > Enables the `ActiveRecord::ConnectionPool` patch to optimize database connection management. Use it to increase throughput under high load.
107
+ #
88
108
  class Rage::Configuration
89
109
  attr_accessor :logger
90
110
  attr_reader :log_formatter, :log_level
111
+ attr_writer :secret_key_base, :fallback_secret_key_base
91
112
 
92
113
  # used in DSL
93
114
  def config = self
@@ -101,6 +122,14 @@ class Rage::Configuration
101
122
  @log_level = level.is_a?(Symbol) ? Logger.const_get(level.to_s.upcase) : level
102
123
  end
103
124
 
125
+ def secret_key_base
126
+ @secret_key_base || ENV["SECRET_KEY_BASE"]
127
+ end
128
+
129
+ def fallback_secret_key_base
130
+ Array(@fallback_secret_key_base || ENV["FALLBACK_SECRET_KEY_BASE"])
131
+ end
132
+
104
133
  def server
105
134
  @server ||= Server.new
106
135
  end
@@ -75,7 +75,7 @@ class RageController::API
75
75
  ""
76
76
  end
77
77
 
78
- activerecord_loaded = Rage.config.internal.rails_mode && defined?(::ActiveRecord)
78
+ activerecord_loaded = defined?(::ActiveRecord)
79
79
 
80
80
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
81
81
  def __run_#{action}
@@ -324,6 +324,18 @@ class RageController::API
324
324
  @response ||= Rage::Response.new(@__headers, @__body)
325
325
  end
326
326
 
327
+ # Get the cookie object. See {Rage::Cookies}.
328
+ # @return [Rage::Cookies]
329
+ def cookies
330
+ @cookies ||= Rage::Cookies.new(@__env, self)
331
+ end
332
+
333
+ # Get the session object. See {Rage::Session}.
334
+ # @return [Rage::Session]
335
+ def session
336
+ @session ||= Rage::Session.new(self)
337
+ end
338
+
327
339
  # Send a response to the client.
328
340
  #
329
341
  # @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
@@ -477,4 +489,9 @@ class RageController::API
477
489
  # def append_info_to_payload(payload)
478
490
  # payload[:response] = response.body
479
491
  # end
492
+
493
+ # Reset the entire session. See {Rage::Session}.
494
+ def reset_session
495
+ session.clear
496
+ end
480
497
  end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "time"
5
+
6
+ if !defined?(DomainName)
7
+ fail <<~ERR
8
+
9
+ rage-rb depends on domain_name to specify the domain name for cookies. Add the following line to your Gemfile:
10
+ gem "domain_name"
11
+
12
+ ERR
13
+ end
14
+
15
+ class Rage::Cookies
16
+ # @private
17
+ def initialize(env, controller)
18
+ @env = env
19
+ @headers = controller.headers
20
+ @request_cookies = {}
21
+ @parsed = false
22
+
23
+ @jar = SimpleJar
24
+ end
25
+
26
+ # Read a cookie.
27
+ #
28
+ # @param key [Symbol]
29
+ # @return [String]
30
+ def [](key)
31
+ value = request_cookies[key]
32
+ @jar.load(value) if value
33
+ end
34
+
35
+ # Get the number of cookies.
36
+ #
37
+ # @return [Integer]
38
+ def size
39
+ request_cookies.count { |_, v| !v.nil? }
40
+ end
41
+
42
+ # Delete a cookie.
43
+ #
44
+ # @param key [Symbol]
45
+ # @param path [String]
46
+ # @param domain [String]
47
+ def delete(key, path: "/", domain: nil)
48
+ @headers.compare_by_identity
49
+
50
+ @request_cookies[key] = nil
51
+ @headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, {
52
+ value: "", expires: Time.at(0), path: path, domain: domain
53
+ })
54
+ end
55
+
56
+ # Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them
57
+ # for read. If the cookie was tampered with by the user (or a 3rd party), `nil` will be returned.
58
+ #
59
+ # This jar requires that you set a suitable secret for the verification on your app's `secret_key_base`.
60
+ #
61
+ # @example
62
+ # cookies.encrypted[:user_id] = current_user.id
63
+ def encrypted
64
+ dup.tap { |c| c.jar = EncryptedJar }
65
+ end
66
+
67
+ # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now.
68
+ #
69
+ # @example
70
+ # cookies.permanent[:user_id] = current_user.id
71
+ def permanent
72
+ dup.tap { |c| c.expires = Time.now + 20 * 365 * 24 * 60 * 60 }
73
+ end
74
+
75
+ # Set a cookie.
76
+ #
77
+ # @param key [Symbol]
78
+ # @param value [String, Hash]
79
+ # @option value [String] :path
80
+ # @option value [Boolean] :secure
81
+ # @option value [Boolean] :httponly
82
+ # @option value [nil, :none, :lax, :strict] :same_site
83
+ # @option value [Time] :expires
84
+ # @option value [String, Array<String>, :all] :domain
85
+ # @option value [String] :value
86
+ # @example
87
+ # cookie[:user_id] = current_user.id
88
+ # @example
89
+ # cookie[:user_id] = { value: current_user.id, httponly: true, secure: true }
90
+ def []=(key, value)
91
+ @headers.compare_by_identity
92
+
93
+ unless value.is_a?(Hash)
94
+ serialized_value = @jar.dump(value)
95
+ @request_cookies[key] = serialized_value
96
+ @headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, { value: serialized_value, expires: @expires })
97
+ return
98
+ end
99
+
100
+ if domain = value[:domain]
101
+ host = @env["HTTP_HOST"]
102
+
103
+ _domain = if domain.is_a?(String)
104
+ domain
105
+ elsif domain == :all
106
+ DomainName(host).domain
107
+ elsif domain.is_a?(Array)
108
+ host if domain.include?(host)
109
+ end
110
+ end
111
+
112
+ serialized_value = @jar.dump(value[:value])
113
+ cookie = Rack::Utils.add_cookie_to_header(nil, key, {
114
+ path: value[:path],
115
+ secure: value[:secure],
116
+ expires: value[:expires] || @expires,
117
+ httponly: value[:httponly],
118
+ same_site: value[:same_site],
119
+ value: serialized_value,
120
+ domain: _domain
121
+ })
122
+
123
+ @request_cookies[key] = serialized_value
124
+ @headers[set_cookie_key(key)] = cookie
125
+ end
126
+
127
+ def inspect
128
+ cookies = request_cookies.transform_values do |v|
129
+ decoded = Base64.urlsafe_decode64(v) rescue nil
130
+ is_encrypted = decoded&.start_with?(EncryptedJar::PADDING)
131
+
132
+ is_encrypted ? "<encrypted>" : v
133
+ end
134
+
135
+ "#<#{self.class.name} @request_cookies=#{cookies.inspect}"
136
+ end
137
+
138
+ private
139
+
140
+ def request_cookies
141
+ return @request_cookies if @parsed
142
+
143
+ @parsed = true
144
+ if cookie_header = @env["HTTP_COOKIE"]
145
+ cookie_header.split(/; */n).each do |cookie|
146
+ next if cookie.empty?
147
+ key, value = cookie.split("=", 2).yield_self { |k, _| [k.to_sym, _] }
148
+ unless @request_cookies.has_key?(key)
149
+ @request_cookies[key] = (Rack::Utils.unescape(value, Encoding::UTF_8) rescue value)
150
+ end
151
+ end
152
+ end
153
+
154
+ @request_cookies
155
+ end
156
+
157
+ def set_cookie_key(key)
158
+ @set_cookie_keys ||= Hash.new { |hash, key| hash[key] = "Set-Cookie".dup }
159
+ @set_cookie_keys[key]
160
+ end
161
+
162
+ protected
163
+
164
+ attr_writer :jar, :expires
165
+
166
+ ####################
167
+ #
168
+ # Cookie Jars
169
+ #
170
+ ####################
171
+
172
+ class SimpleJar
173
+ def self.load(_)
174
+ _
175
+ end
176
+
177
+ def self.dump(value)
178
+ value.to_s
179
+ end
180
+ end
181
+
182
+ class EncryptedJar
183
+ SALT = "encrypted cookie"
184
+ PADDING = "00"
185
+
186
+ class << self
187
+ def load(value)
188
+ box = primary_box
189
+
190
+ begin
191
+ box.decrypt(Base64.urlsafe_decode64(value).byteslice(2..))
192
+ rescue ArgumentError
193
+ nil
194
+ rescue RbNaCl::CryptoError
195
+ i ||= 0
196
+ if box = fallback_boxes[i]
197
+ i += 1
198
+ retry
199
+ end
200
+ end
201
+ end
202
+
203
+ def dump(value)
204
+ # add two bytes to hold meta information, e.g. in case we
205
+ # need to change the encryption algorithm in the future
206
+ Base64.urlsafe_encode64(PADDING + primary_box.encrypt(value.to_s))
207
+ end
208
+
209
+ private
210
+
211
+ def primary_box
212
+ @primary_box ||= begin
213
+ if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0"))
214
+ fail <<~ERR
215
+
216
+ rage-rb depends on rbnacl [>= 3.3, < 8.0] to encrypt cookies. Add the following line to your Gemfile:
217
+ gem "rbnacl"
218
+
219
+ ERR
220
+ end
221
+
222
+ unless Rage.config.secret_key_base
223
+ raise "Rage.config.secret_key_base should be set to use encrypted cookies"
224
+ end
225
+
226
+ RbNaCl::SimpleBox.from_secret_key(
227
+ RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: SALT)
228
+ )
229
+ end
230
+ end
231
+
232
+ def fallback_boxes
233
+ @fallback_boxes ||= begin
234
+ Rage.config.fallback_secret_key_base.map do |key|
235
+ RbNaCl::SimpleBox.from_secret_key(RbNaCl::Hash.blake2b(key, digest_size: 32, salt: SALT))
236
+ end
237
+ end
238
+ end
239
+ end # class << self
240
+ end
241
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rage::Ext::ActiveRecord::ConnectionPool
4
+ # items can be added but not removed
5
+ class BlackHoleList
6
+ def initialize(arr)
7
+ @arr = arr
8
+ end
9
+
10
+ def <<(el)
11
+ @arr << el
12
+ end
13
+
14
+ def shift
15
+ nil
16
+ end
17
+
18
+ def length
19
+ 0
20
+ end
21
+
22
+ def to_a
23
+ @arr
24
+ end
25
+ end
26
+
27
+ def self.extended(instance)
28
+ instance.class.alias_method :__checkout__, :checkout
29
+ instance.class.alias_method :__remove__, :remove
30
+
31
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.attr_accessor(:__idle_since)
32
+ end
33
+
34
+ def __init_rage_extension
35
+ # a map of fibers that are currently waiting for a
36
+ # connection in the format of { Fiber => timestamp }
37
+ @__blocked = {}
38
+
39
+ # a map of fibers that are currently hodling connections
40
+ # in the format of { Fiber => Connection }
41
+ @__in_use = {}
42
+
43
+ # a list of all DB connections that are currently idle
44
+ @__connections = build_new_connections
45
+
46
+ # how long a fiber can wait for a connection to become available
47
+ @__checkout_timeout = checkout_timeout
48
+
49
+ # how long a connection can be idle for before disconnecting
50
+ @__idle_timeout = reaper.frequency
51
+
52
+ # how often should we check for fibers that wait for a connection for too long
53
+ @__timeout_worker_frequency = 0.5
54
+
55
+ # reject fibers that wait for a connection for more than `@__checkout_timeout`
56
+ Iodine.run_every((@__timeout_worker_frequency * 1_000).to_i) do
57
+ if @__blocked.length > 0
58
+ current_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ @__blocked.each do |fiber, blocked_since|
60
+ if (current_time - blocked_since) > @__checkout_timeout
61
+ @__blocked.delete(fiber)
62
+ fiber.raise(ActiveRecord::ConnectionTimeoutError, "could not obtain a connection from the pool within #{@__checkout_timeout} seconds; all pooled connections were in use")
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # resume blocked fibers once connections become available
69
+ Iodine.subscribe("ext:ar-connection-released") do
70
+ if @__blocked.length > 0 && @__connections.length > 0
71
+ f, _ = @__blocked.shift
72
+ f.resume
73
+ end
74
+ end
75
+
76
+ # unsubscribe on shutdown
77
+ Iodine.on_state(:on_finish) do
78
+ Iodine.unsubscribe("ext:ar-connection-released")
79
+ end
80
+ end
81
+
82
+ # Returns true if there is an open connection being used for the current fiber.
83
+ def active_connection?
84
+ @__in_use[Fiber.current]
85
+ end
86
+
87
+ # Retrieve the connection associated with the current fiber, or obtain one if necessary.
88
+ def connection
89
+ @__in_use[Fiber.current] ||= @__connections.shift || begin
90
+ fiber, blocked_since = Fiber.current, Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ @__blocked[fiber] = blocked_since
92
+ Fiber.yield
93
+
94
+ @__connections.shift
95
+ end
96
+ end
97
+
98
+ # Signal that the fiber is finished with the current connection and it can be returned to the pool.
99
+ def release_connection(owner = Fiber.current)
100
+ if conn = @__in_use.delete(owner)
101
+ conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ @__connections << conn
103
+ Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
104
+ end
105
+
106
+ conn
107
+ end
108
+
109
+ # Recover lost connections for the pool.
110
+ def reap
111
+ @__in_use.each do |fiber, conn|
112
+ unless fiber.alive?
113
+ if conn.active?
114
+ conn.reset!
115
+ release_connection(fiber)
116
+ else
117
+ @__in_use.delete(fiber)
118
+ conn.disconnect!
119
+ __remove__(conn)
120
+ @__connections += build_new_connections(1)
121
+ Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ # Disconnect all connections that have been idle for at least
128
+ # `minimum_idle` seconds. Connections currently checked out, or that were
129
+ # checked in less than `minimum_idle` seconds ago, are unaffected.
130
+ def flush(minimum_idle = @__idle_timeout)
131
+ return if minimum_idle.nil? || @__connections.length == 0
132
+
133
+ current_time, i = Process.clock_gettime(Process::CLOCK_MONOTONIC), 0
134
+ while i < @__connections.length
135
+ conn = @__connections[i]
136
+ if conn.__idle_since && current_time - conn.__idle_since >= minimum_idle
137
+ conn.__idle_since = nil
138
+ conn.disconnect!
139
+ end
140
+ i += 1
141
+ end
142
+ end
143
+
144
+ # Disconnect all currently idle connections. Connections currently checked out are unaffected.
145
+ def flush!
146
+ reap
147
+ flush(-1)
148
+ end
149
+
150
+ # Yields a connection from the connection pool to the block.
151
+ def with_connection
152
+ yield connection
153
+ ensure
154
+ release_connection
155
+ end
156
+
157
+ # Returns an array containing the connections currently in the pool.
158
+ def connections
159
+ @__connections.to_a
160
+ end
161
+
162
+ # Returns true if a connection has already been opened.
163
+ def connected?
164
+ true
165
+ end
166
+
167
+ # Return connection pool's usage statistic.
168
+ def stat
169
+ {
170
+ size: size,
171
+ connections: size,
172
+ busy: @__in_use.count { |fiber, _| fiber.alive? },
173
+ dead: @__in_use.count { |fiber, _| !fiber.alive? },
174
+ idle: @__connections.length,
175
+ waiting: @__blocked.length,
176
+ checkout_timeout: @__checkout_timeout
177
+ }
178
+ end
179
+
180
+ # Disconnects all connections in the pool, and clears the pool.
181
+ # Raises `ActiveRecord::ExclusiveConnectionTimeoutError` if unable to gain ownership of all
182
+ # connections in the pool within a timeout interval (default duration is `checkout_timeout * 2` seconds).
183
+ def disconnect(raise_on_acquisition_timeout = true, disconnect_attempts = 0)
184
+ # allow request fibers to release connections, but block from acquiring new ones
185
+ if disconnect_attempts == 0
186
+ @__connections = BlackHoleList.new(@__connections)
187
+ end
188
+
189
+ # if some connections are in use, we will wait for up to `@__checkout_timeout * 2` seconds
190
+ if @__in_use.length > 0 && disconnect_attempts <= @__checkout_timeout * 4
191
+ Iodine.run_after(500) { disconnect(raise_on_acquisition_timeout, disconnect_attempts + 1) }
192
+ return
193
+ end
194
+
195
+ pool_connections = @__connections.to_a
196
+
197
+ # check if there are still some connections in use
198
+ if @__in_use.length > 0
199
+ raise(ActiveRecord::ExclusiveConnectionTimeoutError, "could not obtain ownership of all database connections") if raise_on_acquisition_timeout
200
+ pool_connections += @__in_use.values
201
+ @__in_use.clear
202
+ end
203
+
204
+ # disconnect all connections
205
+ pool_connections.each do |conn|
206
+ conn.disconnect!
207
+ __remove__(conn)
208
+ end
209
+
210
+ # create a new pool
211
+ @__connections = build_new_connections
212
+
213
+ # notify blocked fibers that there are new connections available
214
+ [@__blocked.length, @__connections.length].min.times do
215
+ Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS)
216
+ end
217
+ end
218
+
219
+ # Disconnects all connections in the pool, and clears the pool.
220
+ # The pool first tries to gain ownership of all connections. If unable to
221
+ # do so within a timeout interval (default duration is `checkout_timeout * 2` seconds),
222
+ # then the pool is forcefully disconnected without any regard for other connection owning fibers.
223
+ def disconnect!
224
+ disconnect(false)
225
+ end
226
+
227
+ # Check out a database connection from the pool, indicating that you want
228
+ # to use it. You should call #checkin when you no longer need this.
229
+ def checkout(_ = nil)
230
+ connection
231
+ end
232
+
233
+ # Check in a database connection back into the pool, indicating that you no longer need this connection.
234
+ def checkin(conn)
235
+ fiber = @__in_use.key(conn)
236
+ release_connection(fiber)
237
+ end
238
+
239
+ # Remove a connection from the connection pool. The connection will
240
+ # remain open and active but will no longer be managed by this pool.
241
+ def remove(conn)
242
+ __remove__(conn)
243
+ @__in_use.delete_if { |_, c| c == conn }
244
+ @__connections.delete(conn)
245
+ end
246
+
247
+ def clear_reloadable_connections(raise_on_acquisition_timeout = true)
248
+ disconnect(raise_on_acquisition_timeout)
249
+ end
250
+
251
+ def clear_reloadable_connections!
252
+ disconnect(false)
253
+ end
254
+
255
+ def num_waiting_in_queue
256
+ @__blocked.length
257
+ end
258
+
259
+ # Discards all connections in the pool (even if they're currently in use!),
260
+ # along with the pool itself. Any further interaction with the pool is undefined.
261
+ def discard!
262
+ @__discarded = true
263
+ (@__connections + @__in_use.values).each { |conn| conn.discard! }
264
+ end
265
+
266
+ def discarded?
267
+ !!@__discarded
268
+ end
269
+
270
+ private
271
+
272
+ def build_new_connections(num_connections = size)
273
+ (1..num_connections).map do
274
+ __checkout__.tap { |conn| conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,36 @@
1
+ # set ActiveSupport isolation level
2
+ if defined?(ActiveSupport::IsolatedExecutionState)
3
+ ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
4
+ end
5
+
6
+ # release ActiveRecord connections on yield
7
+ if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1.0")
8
+ class Fiber
9
+ def self.defer
10
+ res = Fiber.yield
11
+
12
+ if ActiveRecord::Base.connection_pool.active_connection?
13
+ ActiveRecord::Base.connection_handler.clear_active_connections!
14
+ end
15
+
16
+ res
17
+ end
18
+ end
19
+ end
20
+
21
+ # make `ActiveRecord::ConnectionPool` work correctly with fibers
22
+ if defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
23
+ ActiveRecord::ConnectionAdapters::ConnectionPool
24
+ module ActiveRecord::ConnectionAdapters
25
+ class ConnectionPool
26
+ def connection_cache_key(_)
27
+ Fiber.current
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # patch `ActiveRecord::ConnectionPool`
34
+ if defined?(ActiveRecord) && ENV["RAGE_PATCH_AR_POOL"]
35
+ Rage.patch_active_record_connection_pool
36
+ end
data/lib/rage/fiber.rb CHANGED
@@ -91,7 +91,7 @@ class Fiber
91
91
 
92
92
  # @private
93
93
  # under normal circumstances, the method is a copy of `yield`, but it can be overriden to perform
94
- # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/rails.rb"
94
+ # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/ext/setup.rb"
95
95
  class << self
96
96
  alias_method :defer, :yield
97
97
  end
data/lib/rage/rails.rb CHANGED
@@ -11,34 +11,6 @@ Iodine.patch_rack
11
11
  # configure the framework
12
12
  Rage.config.internal.rails_mode = true
13
13
 
14
- # patch ActiveRecord's connection pool
15
- if defined?(ActiveRecord)
16
- Rails.configuration.after_initialize do
17
- module ActiveRecord::ConnectionAdapters
18
- class ConnectionPool
19
- def connection_cache_key(_)
20
- Fiber.current
21
- end
22
- end
23
- end
24
- end
25
- end
26
-
27
- # release ActiveRecord connections on yield
28
- if defined?(ActiveRecord)
29
- class Fiber
30
- def self.defer
31
- res = Fiber.yield
32
-
33
- if ActiveRecord::Base.connection_pool.active_connection?
34
- ActiveRecord::Base.connection_handler.clear_active_connections!
35
- end
36
-
37
- res
38
- end
39
- end
40
- end
41
-
42
14
  # plug into Rails' Zeitwerk instance to reload the code
43
15
  Rails.autoloaders.main.on_setup do
44
16
  if Iodine.running?
@@ -71,6 +43,10 @@ end
71
43
  Rails.configuration.after_initialize do
72
44
  if Rails.logger && !Rage.logger
73
45
  rails_logdev = Rails.logger.instance_variable_get(:@logdev)
74
- Rage.config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice)
46
+ Rage.configure do
47
+ config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice)
48
+ end
75
49
  end
76
50
  end
51
+
52
+ require "rage/ext/setup"
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class Rage::Session
6
+ # @private
7
+ KEY = Rack::RACK_SESSION.to_sym
8
+
9
+ # @private
10
+ def initialize(controller)
11
+ @cookies = controller.cookies.encrypted
12
+ end
13
+
14
+ # Writes the value to the session.
15
+ #
16
+ # @param key [Symbol]
17
+ # @param value [String]
18
+ def []=(key, value)
19
+ write_session(add: { key => value })
20
+ end
21
+
22
+ # Returns the value of the key stored in the session or `nil` if the given key is not found.
23
+ #
24
+ # @param key [Symbol]
25
+ def [](key)
26
+ read_session[key]
27
+ end
28
+
29
+ # Returns the value of the given key from the session, or raises `KeyError` if the given key is not found
30
+ # and no default value is set. Returns the default value if specified.
31
+ #
32
+ # @param key [Symbol]
33
+ def fetch(key, default = nil, &block)
34
+ if default.nil?
35
+ read_session.fetch(key, &block)
36
+ else
37
+ read_session.fetch(key, default, &block)
38
+ end
39
+ end
40
+
41
+ # Deletes the given key from the session.
42
+ #
43
+ # @param key [Symbol]
44
+ def delete(key)
45
+ write_session(remove: key)
46
+ end
47
+
48
+ # Clears the session.
49
+ def clear
50
+ write_session(clear: true)
51
+ end
52
+
53
+ # Returns the session as Hash.
54
+ def to_hash
55
+ read_session
56
+ end
57
+
58
+ alias_method :to_h, :to_hash
59
+
60
+ def empty?
61
+ read_session.empty?
62
+ end
63
+
64
+ # Returns `true` if the given key is present in the session.
65
+ def has_key?(key)
66
+ read_session.has_key?(key)
67
+ end
68
+
69
+ alias_method :key?, :has_key?
70
+ alias_method :include?, :has_key?
71
+
72
+ def each(&block)
73
+ read_session.each(&block)
74
+ end
75
+
76
+ def dig(*keys)
77
+ read_session.dig(*keys)
78
+ end
79
+
80
+ def inspect
81
+ "#<#{self.class.name} @session=#{to_h.inspect}"
82
+ end
83
+
84
+ private
85
+
86
+ def write_session(add: nil, remove: nil, clear: nil)
87
+ if add
88
+ read_session.merge!(add)
89
+ elsif remove && read_session.has_key?(remove)
90
+ read_session.reject! { |k, _| k == remove }
91
+ elsif clear
92
+ read_session.clear
93
+ end
94
+
95
+ @cookies[KEY] = { httponly: true, same_site: :lax, value: read_session.to_json }
96
+ end
97
+
98
+ def read_session
99
+ @session ||= begin
100
+ JSON.parse(@cookies[KEY] || "{}", symbolize_names: true)
101
+ rescue JSON::ParserError
102
+ {}
103
+ end
104
+ end
105
+ end
data/lib/rage/setup.rb CHANGED
@@ -9,3 +9,5 @@ Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initia
9
9
  Rage.code_loader.setup
10
10
 
11
11
  require_relative "#{Rage.root}/config/routes"
12
+
13
+ require "rage/ext/setup"
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "rage-rb", "<%= Rage::VERSION %>"
3
+ gem "rage-rb", "~> <%= Rage::VERSION[0..2] %>"
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.2.2"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -71,10 +71,40 @@ module Rage
71
71
  @code_loader ||= Rage::CodeLoader.new
72
72
  end
73
73
 
74
+ def self.patch_active_record_connection_pool
75
+ patch = proc do
76
+ is_connected = ActiveRecord::Base.connection_pool rescue false
77
+ if is_connected
78
+ puts "INFO: Patching ActiveRecord::ConnectionPool"
79
+ Iodine.on_state(:on_start) do
80
+ ActiveRecord::Base.connection_pool.extend(Rage::Ext::ActiveRecord::ConnectionPool)
81
+ ActiveRecord::Base.connection_pool.__init_rage_extension
82
+ end
83
+ else
84
+ puts "WARNING: DB connection is not established - can't patch ActiveRecord::ConnectionPool"
85
+ end
86
+ end
87
+
88
+ if Rage.config.internal.rails_mode
89
+ Rails.configuration.after_initialize(&patch)
90
+ else
91
+ patch.call
92
+ end
93
+ end
94
+
74
95
  module Router
75
96
  module Strategies
76
97
  end
77
98
  end
99
+
100
+ module Ext
101
+ module ActiveRecord
102
+ autoload :ConnectionPool, "rage/ext/active_record/connection_pool"
103
+ end
104
+ end
105
+
106
+ autoload :Cookies, "rage/cookies"
107
+ autoload :Session, "rage/session"
78
108
  end
79
109
 
80
110
  module RageController
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.2.2
4
+ version: 1.4.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-04-03 00:00:00.000000000 Z
11
+ date: 2024-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -105,8 +105,11 @@ files:
105
105
  - lib/rage/code_loader.rb
106
106
  - lib/rage/configuration.rb
107
107
  - lib/rage/controller/api.rb
108
+ - lib/rage/cookies.rb
108
109
  - lib/rage/env.rb
109
110
  - lib/rage/errors.rb
111
+ - lib/rage/ext/active_record/connection_pool.rb
112
+ - lib/rage/ext/setup.rb
110
113
  - lib/rage/fiber.rb
111
114
  - lib/rage/fiber_scheduler.rb
112
115
  - lib/rage/logger/json_formatter.rb
@@ -128,6 +131,7 @@ files:
128
131
  - lib/rage/router/strategies/host.rb
129
132
  - lib/rage/router/util.rb
130
133
  - lib/rage/rspec.rb
134
+ - lib/rage/session.rb
131
135
  - lib/rage/setup.rb
132
136
  - lib/rage/sidekiq_session.rb
133
137
  - lib/rage/templates/Gemfile