rage-rb 1.2.2 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
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