rage-rb 0.6.0 → 0.7.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: 0eed317e69386a2c2cc765df7be687be754934801390e96013902e78670171eb
4
- data.tar.gz: 44c899c5843d112ca222df4b863852df9e3728a63954ee3b5ab4ffd2cffd4e2e
3
+ metadata.gz: 1c3041038ae63ae245e261a7a2096195ab56291d236414e086646ae96a4a6d16
4
+ data.tar.gz: a64817ded16716fe714310c7318d8e1d318454615da43d37055b333e76778fa2
5
5
  SHA512:
6
- metadata.gz: 4e158fd8202e9c6e4f94a7b243383c34f2179306a10724978083b6599197197221915857540ae2beb5ea98a5f602c6507bec89d192a57706b3aae2203c75a8a3
7
- data.tar.gz: e9723bb6a0ef7b1a67db08799e3429ae4c2e9f88894afb9e614645673df7cb4e019107f0ca1339eb521fa7958fd259ca032385f98fdbddacd59415d960f0c347
6
+ metadata.gz: 350f5150012852a3ca2532c031ce8ef28338da6d985ef66dff59095dea6b3dbbc4498826996876722bc5b7991683fed82c2fd3854313f7ed59f9fff20aaf4315
7
+ data.tar.gz: 84cd798056a43c8eb0e94809f72f6b0ac602076aa904831a3a049a8ed13352cad9321329233063343b73e65c0fbf1521564b0cee6f3696c89877a0af15191c94
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2024-01-09
4
+
5
+ - Add conditional GET using `stale?` by [@tonekk](https://github.com/tonekk) (#55).
6
+ - Add Rails integration (#57).
7
+ - Add JSON log formatter (#59).
8
+
3
9
  ## [0.6.0] - 2023-12-22
4
10
 
5
11
  ### Added
data/README.md CHANGED
@@ -143,17 +143,17 @@ end
143
143
 
144
144
  ## Upcoming releases
145
145
 
146
- Version | Changes
147
- ------- |------------
148
- 0.2 :white_check_mark: | ~~Gem configuration by env.<br>Add `skip_before_action`.<br>Add `rescue_from`.<br>Router updates:<br>&emsp;• make the `root` helper work correctly with `scope`;<br>&emsp;• support the `defaults` option;~~
149
- 0.3 :white_check_mark: | ~~CLI updates:<br>&emsp;• `routes` task;<br>&emsp;• `console` task;<br>Support the `:if` and `:unless` options in `before_action`.<br>Allow to set response headers.~~
150
- 0.4 :white_check_mark: | ~~Expose the `params` object.<br>Support header authentication with `authenticate_with_http_token`.<br>Router updates:<br>&emsp;• add the `resources` route helper;<br>&emsp;• add the `namespace` route helper;~~
151
- 0.5 :white_check_mark: | ~~Add request logging.~~
152
- 0.6 | Automatic code reloading in development with Zeitwerk.
153
- 0.7 | Expose the `send_data` and `send_file` methods.
154
- 0.8 | Support conditional get with `etag` and `last_modified`.
155
- 0.9 | Expose the `cookies` and `session` objects.
156
- 1.0 | Implement Iodine-based equivalent of Action Cable.
146
+ Status | Changes
147
+ -- | ------------
148
+ :white_check_mark: | ~~Gem configuration by env.<br>Add `skip_before_action`.<br>Add `rescue_from`.<br>Router updates:<br>&emsp;• make the `root` helper work correctly with `scope`;<br>&emsp;• support the `defaults` option;~~
149
+ :white_check_mark: | ~~CLI updates:<br>&emsp;• `routes` task;<br>&emsp;• `console` task;<br>Support the `:if` and `:unless` options in `before_action`.<br>Allow to set response headers.~~
150
+ :white_check_mark: | ~~Expose the `params` object.<br>Support header authentication with `authenticate_with_http_token`.<br>Router updates:<br>&emsp;• add the `resources` route helper;<br>&emsp;• add the `namespace` route helper;~~
151
+ :white_check_mark: | ~~Add request logging.~~
152
+ :white_check_mark: | ~~Automatic code reloading in development with Zeitwerk.~~
153
+ | Expose the `send_data` and `send_file` methods.
154
+ | Support conditional get with `etag` and `last_modified`.
155
+ | Expose the `cookies` and `session` objects.
156
+ | Implement Iodine-based equivalent of Action Cable.
157
157
 
158
158
  ## Development
159
159
 
data/lib/rage/all.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "fiber"
6
6
  require_relative "fiber_scheduler"
7
7
  require_relative "configuration"
8
8
  require_relative "request"
9
+ require_relative "response"
9
10
  require_relative "uploaded_file"
10
11
  require_relative "errors"
11
12
  require_relative "params_parser"
@@ -21,6 +22,7 @@ require_relative "router/node"
21
22
  require_relative "controller/api"
22
23
 
23
24
  require_relative "logger/text_formatter"
25
+ require_relative "logger/json_formatter"
24
26
  require_relative "logger/logger"
25
27
 
26
28
  require_relative "middleware/fiber_wrapper"
@@ -3,6 +3,7 @@
3
3
  class Rage::Application
4
4
  def initialize(router)
5
5
  @router = router
6
+ @exception_app = build_exception_app
6
7
  end
7
8
 
8
9
  def call(env)
@@ -17,10 +18,11 @@ class Rage::Application
17
18
  [404, {}, ["Not Found"]]
18
19
  end
19
20
 
21
+ rescue Rage::Errors::BadRequest => e
22
+ response = @exception_app.call(400, e)
23
+
20
24
  rescue Exception => e
21
- exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
22
- Rage.logger.error(exception_str)
23
- response = [500, {}, [exception_str]]
25
+ response = @exception_app.call(500, e)
24
26
 
25
27
  ensure
26
28
  finalize_logger(env, response, params)
@@ -50,4 +52,20 @@ class Rage::Application
50
52
  Rage.logger.info("")
51
53
  logger[:final] = nil
52
54
  end
55
+
56
+ def build_exception_app
57
+ if Rage.env.development?
58
+ ->(status, e) do
59
+ exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
60
+ Rage.logger.error(exception_str)
61
+ [status, {}, [exception_str]]
62
+ end
63
+ else
64
+ ->(status, e) do
65
+ exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
66
+ Rage.logger.error(exception_str)
67
+ [status, {}, []]
68
+ end
69
+ end
70
+ end
53
71
  end
data/lib/rage/cli.rb CHANGED
@@ -18,13 +18,14 @@ module Rage
18
18
  option :port, aliases: "-p", desc: "Runs Rage on the specified port - defaults to 3000."
19
19
  option :environment, aliases: "-e", desc: "Specifies the environment to run this server under (test/development/production)."
20
20
  option :binding, aliases: "-b", desc: "Binds Rails to the specified IP - defaults to 'localhost' in development and '0.0.0.0' in other environments."
21
+ option :config, aliases: "-c", desc: "Uses a custom rack configuration."
21
22
  option :help, aliases: "-h", desc: "Show this message."
22
23
  def server
23
24
  return help("server") if options.help?
24
25
 
25
26
  set_env(options)
26
27
 
27
- app = ::Rack::Builder.parse_file("config.ru")
28
+ app = ::Rack::Builder.parse_file(options[:config] || "config.ru")
28
29
  app = app[0] if app.is_a?(Array)
29
30
 
30
31
  port = options[:port] || Rage.config.server.port
@@ -114,6 +115,12 @@ module Rage
114
115
 
115
116
  def environment
116
117
  require File.expand_path("config/application.rb", Dir.pwd)
118
+
119
+ # in Rails mode we delegate code loading to Rails, and thus need
120
+ # to manually load application code for CLI utilities to work
121
+ if Rage.config.internal.rails_mode
122
+ require "rage/setup"
123
+ end
117
124
  end
118
125
 
119
126
  def set_env(options)
@@ -4,11 +4,12 @@ require "zeitwerk"
4
4
 
5
5
  class Rage::CodeLoader
6
6
  def initialize
7
- @loader = Zeitwerk::Loader.new
8
7
  @reloading = false
9
8
  end
10
9
 
11
10
  def setup
11
+ @loader = Zeitwerk::Loader.new
12
+
12
13
  autoload_path = "#{Rage.root}/app"
13
14
  enable_reloading = Rage.env.development?
14
15
  enable_eager_loading = !Rage.env.development? && !Rage.env.test?
@@ -23,13 +24,24 @@ class Rage::CodeLoader
23
24
  @loader.eager_load if enable_eager_loading
24
25
  end
25
26
 
27
+ # in standalone mode - reload the code and the routes
26
28
  def reload
29
+ return unless @loader
30
+
27
31
  @reloading = true
28
32
  @loader.reload
29
33
  Rage.__router.reset_routes
30
34
  load("#{Rage.root}/config/routes.rb")
31
35
  end
32
36
 
37
+ # in Rails mode - reset the routes; everything else will be done by Rails
38
+ def rails_mode_reload
39
+ return if @loader
40
+
41
+ @reloading = true
42
+ Rage.__router.reset_routes
43
+ end
44
+
33
45
  def reloading?
34
46
  @reloading
35
47
  end
@@ -109,6 +109,10 @@ class Rage::Configuration
109
109
  @middleware ||= Middleware.new
110
110
  end
111
111
 
112
+ def internal
113
+ @internal ||= Internal.new
114
+ end
115
+
112
116
  class Server
113
117
  attr_accessor :port, :workers_count, :timeout, :max_clients
114
118
  attr_reader :threads_count
@@ -160,6 +164,15 @@ class Rage::Configuration
160
164
  end
161
165
  end
162
166
 
167
+ # @private
168
+ class Internal
169
+ attr_accessor :rails_mode, :rails_console
170
+
171
+ def inspect
172
+ "#<#{self.class.name}>"
173
+ end
174
+ end
175
+
163
176
  # @private
164
177
  def __finalize
165
178
  if @logger
@@ -75,8 +75,16 @@ class RageController::API
75
75
  ""
76
76
  end
77
77
 
78
+ activerecord_loaded = Rage.config.internal.rails_mode && defined?(::ActiveRecord)
79
+
78
80
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
79
81
  def __run_#{action}
82
+ #{if activerecord_loaded
83
+ <<~RUBY
84
+ ActiveRecord::Base.connection_pool.enable_query_cache!
85
+ RUBY
86
+ end}
87
+
80
88
  #{before_actions_chunk}
81
89
  #{action}
82
90
 
@@ -90,6 +98,24 @@ class RageController::API
90
98
  [@__status, @__headers, @__body]
91
99
 
92
100
  #{rescue_handlers_chunk}
101
+
102
+ ensure
103
+ #{if activerecord_loaded
104
+ <<~RUBY
105
+ ActiveRecord::Base.connection_pool.disable_query_cache!
106
+ if ActiveRecord::Base.connection_pool.active_connection?
107
+ ActiveRecord::Base.connection_handler.clear_active_connections!
108
+ end
109
+ RUBY
110
+ end}
111
+
112
+ #{if method_defined?(:append_info_to_payload) || private_method_defined?(:append_info_to_payload)
113
+ <<~RUBY
114
+ context = {}
115
+ append_info_to_payload(context)
116
+ Thread.current[:rage_logger][:context] = context
117
+ RUBY
118
+ end}
93
119
  end
94
120
  RUBY
95
121
  end
@@ -196,6 +222,16 @@ class RageController::API
196
222
  end
197
223
  end
198
224
 
225
+ # Register a new `after_action` hook. Calls with the same `action_name` will overwrite the previous ones.
226
+ #
227
+ # @param action_name [String, nil] the name of the callback to add
228
+ # @param [Hash] opts action options
229
+ # @option opts [Symbol, Array<Symbol>] :only restrict the callback to run only for specific actions
230
+ # @option opts [Symbol, Array<Symbol>] :except restrict the callback to run for all actions except specified
231
+ # @option opts [Symbol, Proc] :if only run the callback if the condition is true
232
+ # @option opts [Symbol, Proc] :unless only run the callback if the condition is false
233
+ # @example
234
+ # after_action :log_detailed_metrics, only: :create
199
235
  def after_action(action_name = nil, **opts, &block)
200
236
  action = prepare_action_params(action_name, **opts, &block)
201
237
 
@@ -268,22 +304,26 @@ class RageController::API
268
304
  end
269
305
  end # class << self
270
306
 
271
- # @private
272
- DEFAULT_HEADERS = { "content-type" => "application/json; charset=utf-8" }.freeze
273
-
274
307
  # @private
275
308
  def initialize(env, params)
276
309
  @__env = env
277
310
  @__params = params
278
- @__status, @__headers, @__body = 204, DEFAULT_HEADERS, []
311
+ @__status, @__headers, @__body = 204, { "content-type" => "application/json; charset=utf-8" }, []
279
312
  @__rendered = false
280
313
  end
281
314
 
282
315
  # Get the request object. See {Rage::Request}.
316
+ # @return [Rage::Request]
283
317
  def request
284
318
  @request ||= Rage::Request.new(@__env)
285
319
  end
286
320
 
321
+ # Get the response object. See {Rage::Response}.
322
+ # @return [Rage::Response]
323
+ def response
324
+ @response ||= Rage::Response.new(@__headers, @__body)
325
+ end
326
+
287
327
  # Send a response to the client.
288
328
  #
289
329
  # @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
@@ -339,11 +379,10 @@ class RageController::API
339
379
 
340
380
  # Set response headers.
341
381
  #
382
+ # @return [Hash]
342
383
  # @example
343
384
  # headers["Content-Type"] = "application/pdf"
344
385
  def headers
345
- # copy-on-write implementation for the headers object
346
- @__headers = {}.merge!(@__headers) if DEFAULT_HEADERS.equal?(@__headers)
347
386
  @__headers
348
387
  end
349
388
 
@@ -357,11 +396,24 @@ class RageController::API
357
396
  def authenticate_with_http_token
358
397
  auth_header = @__env["HTTP_AUTHORIZATION"]
359
398
 
360
- if auth_header&.start_with?("Bearer")
361
- yield auth_header[7..]
399
+ payload = if auth_header&.start_with?("Bearer")
400
+ auth_header[7..]
362
401
  elsif auth_header&.start_with?("Token")
363
- yield auth_header[6..]
402
+ auth_header[6..]
364
403
  end
404
+
405
+ return unless payload
406
+
407
+ token = if payload.start_with?("token=")
408
+ payload[6..]
409
+ else
410
+ payload
411
+ end
412
+
413
+ token.delete_prefix!('"')
414
+ token.delete_suffix!('"')
415
+
416
+ yield token
365
417
  end
366
418
 
367
419
  if !defined?(::ActionController::Parameters)
@@ -389,4 +441,40 @@ class RageController::API
389
441
  @params ||= ActionController::Parameters.new(@__params)
390
442
  end
391
443
  end
444
+
445
+ # Checks if the request is stale to decide if the action has to be rendered or the cached version is still valid. Use this method to implement conditional GET.
446
+ #
447
+ # @param etag [String] The etag of the requested resource.
448
+ # @param last_modified [Time] The last modified time of the requested resource.
449
+ # @return [Boolean] True if the response is stale, false otherwise.
450
+ # @example
451
+ # stale?(etag: "123", last_modified: Time.utc(2023, 12, 15))
452
+ # stale?(last_modified: Time.utc(2023, 12, 15))
453
+ # stale?(etag: "123")
454
+ # @note `stale?` will set the response status to 304 if the request is fresh. This side effect will cause a double render error, if `render` gets called after this method. Make sure to implement a proper conditional in your action to prevent this from happening:
455
+ # ```ruby
456
+ # if stale?(etag: "123")
457
+ # render json: { hello: "world" }
458
+ # end
459
+ # ```
460
+ def stale?(etag: nil, last_modified: nil)
461
+ still_fresh = request.fresh?(etag:, last_modified:)
462
+
463
+ head :not_modified if still_fresh
464
+ !still_fresh
465
+ end
466
+
467
+ # @private
468
+ # for comatibility with `Rails.application.routes.recognize_path`
469
+ def self.binary_params_for?(_)
470
+ false
471
+ end
472
+
473
+ # @!method append_info_to_payload(payload)
474
+ # Define this method to add more information to request logs.
475
+ # @param [Hash] payload the payload to add additional information to
476
+ # @example
477
+ # def append_info_to_payload(payload)
478
+ # payload[:response] = response.body
479
+ # end
392
480
  end
@@ -0,0 +1,44 @@
1
+ class Rage::JSONFormatter
2
+ def initialize
3
+ @pid = Process.pid.to_s
4
+ Iodine.on_state(:on_start) do
5
+ @pid = Process.pid.to_s
6
+ end
7
+ end
8
+
9
+ def call(severity, timestamp, _, message)
10
+ logger = Thread.current[:rage_logger]
11
+ tags, context = logger[:tags], logger[:context]
12
+
13
+ if !context.empty?
14
+ context_msg = ""
15
+ context.each { |k, v| context_msg << "\"#{k}\":#{v.to_json}," }
16
+ end
17
+
18
+ if final = logger[:final]
19
+ params, env = final[:params], final[:env]
20
+ if params
21
+ return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",\"controller\":\"#{params[:controller]}\",\"action\":\"#{params[:action]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n"
22
+ else
23
+ # no controller/action keys are written if there are no params
24
+ return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n"
25
+ end
26
+ end
27
+
28
+ if tags.length == 1
29
+ tags_msg = "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
30
+ elsif tags.length == 2
31
+ tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
32
+ else
33
+ tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\""
34
+ i = 2
35
+ while i < tags.length
36
+ tags_msg << ",\"#{tags[i]}\""
37
+ i += 1
38
+ end
39
+ tags_msg << "],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
40
+ end
41
+
42
+ "#{tags_msg},#{context_msg}\"message\":\"#{message}\"}\n"
43
+ end
44
+ end
@@ -128,8 +128,8 @@ class Rage::Logger
128
128
  false
129
129
  end
130
130
  RUBY
131
- elsif defined?(IRB)
132
- # the call was made from IRB - don't use the formatter
131
+ elsif (Rage.config.internal.rails_mode ? Rage.config.internal.rails_console : defined?(IRB))
132
+ # the call was made from the console - don't use the formatter
133
133
  <<-RUBY
134
134
  def #{level_name}(msg = nil)
135
135
  @logdev.write((msg || yield) + "\n")
@@ -8,15 +8,20 @@ class Rage::TextFormatter
8
8
 
9
9
  def call(severity, timestamp, _, message)
10
10
  logger = Thread.current[:rage_logger]
11
- tags = logger[:tags]
11
+ tags, context = logger[:tags], logger[:context]
12
+
13
+ if !context.empty?
14
+ context_msg = ""
15
+ context.each { |k, v| context_msg << "#{k}=#{v} " }
16
+ end
12
17
 
13
18
  if final = logger[:final]
14
19
  params, env = final[:params], final[:env]
15
20
  if params
16
- return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} controller=#{params[:controller]} action=#{params[:action]} status=#{final[:response][0]} duration=#{final[:duration]}\n"
21
+ return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} controller=#{params[:controller]} action=#{params[:action]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n"
17
22
  else
18
23
  # no controller/action keys are written if there are no params
19
- return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} status=#{final[:response][0]} duration=#{final[:duration]}\n"
24
+ return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n"
20
25
  end
21
26
  end
22
27
 
@@ -34,13 +39,6 @@ class Rage::TextFormatter
34
39
  tags_msg << " timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
35
40
  end
36
41
 
37
- context = logger[:context]
38
-
39
- if !context.empty?
40
- context_msg = ""
41
- context.each { |k, v| context_msg << "#{k}=#{v} " }
42
- end
43
-
44
42
  "#{tags_msg} #{context_msg}message=#{message}\n"
45
43
  end
46
44
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::Cors
4
+ # @private
4
5
  def initialize(app, *, &)
5
6
  @app = app
6
7
  instance_eval(&)
7
8
  end
8
9
 
10
+ # @private
9
11
  def call(env)
10
12
  if env["REQUEST_METHOD"] == "OPTIONS"
11
13
  return (response = @cors_response)
@@ -17,7 +19,7 @@ class Rage::Cors
17
19
 
18
20
  response
19
21
  ensure
20
- if origin = @cors_check.call(env)
22
+ if !$! && origin = @cors_check.call(env)
21
23
  headers = response[1]
22
24
  headers["Access-Control-Allow-Origin"] = origin
23
25
  if @origins != "*"
data/lib/rage/rails.rb ADDED
@@ -0,0 +1,62 @@
1
+ if Gem::Version.new(Rails.version) < Gem::Version.new(6)
2
+ fail "Rage is only compatible with Rails 6+. Detected Rails version: #{Rails.version}."
3
+ end
4
+
5
+ # load the framework
6
+ require "rage/all"
7
+
8
+ # patch Rack
9
+ Iodine.patch_rack
10
+
11
+ # configure the framework
12
+ Rage.config.internal.rails_mode = true
13
+
14
+ # make sure log formatter is not used in console
15
+ Rails.application.console do
16
+ Rage.config.internal.rails_console = true
17
+ Rage.logger.level = Rage.logger.level if Rage.logger # trigger redefining log methods
18
+ end
19
+
20
+ # patch ActiveRecord's connection pool
21
+ if defined?(ActiveRecord)
22
+ Rails.configuration.after_initialize do
23
+ module ActiveRecord::ConnectionAdapters
24
+ class ConnectionPool
25
+ def connection_cache_key(_)
26
+ Fiber.current
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # plug into Rails' Zeitwerk instance to reload the code
34
+ Rails.autoloaders.main.on_setup do
35
+ if Iodine.running?
36
+ Rage.code_loader.rails_mode_reload
37
+ end
38
+ end
39
+
40
+ # patch `ActionDispatch::Reloader` to synchronize `reload!` calls
41
+ Rails.configuration.after_initialize do
42
+ conditional_mutex = Module.new do
43
+ def call(env)
44
+ @mutex ||= Mutex.new
45
+ if Rails.application.reloader.check!
46
+ @mutex.synchronize { super }
47
+ else
48
+ super
49
+ end
50
+ end
51
+ end
52
+
53
+ ActionDispatch::Reloader.prepend(conditional_mutex)
54
+ end
55
+
56
+ # clone Rails logger
57
+ Rails.configuration.after_initialize do
58
+ if Rails.logger && !Rage.logger
59
+ rails_logdev = Rails.logger.instance_variable_get(:@logdev)
60
+ Rage.config.logger = Rage::Logger.new(rails_logdev) if rails_logdev.is_a?(Logger::LogDevice)
61
+ end
62
+ end
data/lib/rage/request.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  class Rage::Request
4
6
  # @private
5
7
  def initialize(env)
@@ -14,6 +16,55 @@ class Rage::Request
14
16
  @headers ||= Headers.new(@env)
15
17
  end
16
18
 
19
+ # Check if the request is fresh.
20
+ # @param etag [String] The etag of the requested resource.
21
+ # @param last_modified [Time] The last modified time of the requested resource.
22
+ # @return [Boolean] True if the request is fresh, false otherwise.
23
+ # @example
24
+ # request.fresh?(etag: "123", last_modified: Time.utc(2023, 12, 15))
25
+ # request.fresh?(last_modified: Time.utc(2023, 12, 15))
26
+ # request.fresh?(etag: "123")
27
+ def fresh?(etag:, last_modified:)
28
+ # Always render response when no freshness information
29
+ # is provided in the request.
30
+ return false unless if_none_match || if_not_modified_since
31
+
32
+ etag_matches?(
33
+ requested_etags: if_none_match, response_etag: etag
34
+ ) && not_modified?(
35
+ request_not_modified_since: if_not_modified_since,
36
+ response_last_modified: last_modified
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def if_none_match
43
+ headers["HTTP_IF_NONE_MATCH"]
44
+ end
45
+
46
+ def if_not_modified_since
47
+ headers["HTTP_IF_MODIFIED_SINCE"] ? Time.httpdate(headers["HTTP_IF_MODIFIED_SINCE"]) : nil
48
+ rescue ArgumentError
49
+ nil
50
+ end
51
+
52
+ def etag_matches?(requested_etags:, response_etag:)
53
+ requested_etags = requested_etags ? requested_etags.split(",").each(&:strip!) : []
54
+
55
+ return true if requested_etags.empty?
56
+ return false if response_etag.nil?
57
+
58
+ requested_etags.include?(response_etag) || requested_etags.include?("*")
59
+ end
60
+
61
+ def not_modified?(request_not_modified_since:, response_last_modified:)
62
+ return true if request_not_modified_since.nil?
63
+ return false if response_last_modified.nil?
64
+
65
+ request_not_modified_since >= response_last_modified
66
+ end
67
+
17
68
  # @private
18
69
  class Headers
19
70
  HTTP = "HTTP_"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Response
4
+ # @private
5
+ def initialize(headers, body)
6
+ @headers = headers
7
+ @body = body
8
+ end
9
+
10
+ # Returns the content of the response as a string. This contains the contents of any calls to `render`.
11
+ # @return [String]
12
+ def body
13
+ @body[0]
14
+ end
15
+
16
+ # Returns the headers for the response.
17
+ # @return [Hash]
18
+ def headers
19
+ @headers
20
+ end
21
+ end
@@ -7,6 +7,8 @@ class Rage::Router::DSL
7
7
 
8
8
  def draw(&block)
9
9
  Handler.new(@router).instance_eval(&block)
10
+ # propagate route definitions to Rails for `rails routes` to work
11
+ Rails.application.routes.draw(&block) if Rage.config.internal.rails_mode
10
12
  end
11
13
 
12
14
  ##
@@ -283,7 +285,7 @@ class Rage::Router::DSL
283
285
  end
284
286
 
285
287
  _module, _path, _only, _except, _param = opts.values_at(:module, :path, :only, :except, :param)
286
- raise ":param option can't contain colons" if _param&.include?(":")
288
+ raise ":param option can't contain colons" if _param.to_s.include?(":")
287
289
 
288
290
  _only = Array(_only) if _only
289
291
  _except = Array(_except) if _except
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -45,6 +45,24 @@ module Rage
45
45
 
46
46
  def self.load_middlewares(rack_builder)
47
47
  config.middleware.middlewares.each do |middleware, args, block|
48
+ # in Rails compatibility mode we first check if the middleware is a part of the Rails middleware stack;
49
+ # if it is - it is expected to be built using `ActionDispatch::MiddlewareStack::Middleware#build`, but Rack
50
+ # expects the middleware to respond to `#new`, so we wrap the middleware into a helper module
51
+ if Rage.config.internal.rails_mode
52
+ rails_middleware = Rails.application.config.middleware.middlewares.find { |m| m.name == middleware.name }
53
+ if rails_middleware
54
+ wrapper = Module.new do
55
+ extend self
56
+ attr_accessor :middleware
57
+ def new(app, *, &)
58
+ middleware.build(app)
59
+ end
60
+ end
61
+ wrapper.middleware = rails_middleware
62
+ middleware = wrapper
63
+ end
64
+ end
65
+
48
66
  rack_builder.use(middleware, *args, &block)
49
67
  end
50
68
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.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: 2023-12-22 00:00:00.000000000 Z
11
+ date: 2024-01-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -95,13 +95,16 @@ files:
95
95
  - lib/rage/errors.rb
96
96
  - lib/rage/fiber.rb
97
97
  - lib/rage/fiber_scheduler.rb
98
+ - lib/rage/logger/json_formatter.rb
98
99
  - lib/rage/logger/logger.rb
99
100
  - lib/rage/logger/text_formatter.rb
100
101
  - lib/rage/middleware/cors.rb
101
102
  - lib/rage/middleware/fiber_wrapper.rb
102
103
  - lib/rage/middleware/reloader.rb
103
104
  - lib/rage/params_parser.rb
105
+ - lib/rage/rails.rb
104
106
  - lib/rage/request.rb
107
+ - lib/rage/response.rb
105
108
  - lib/rage/router/README.md
106
109
  - lib/rage/router/backend.rb
107
110
  - lib/rage/router/constrainer.rb