rage-rb 0.6.0 → 1.0.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: 9bb19c01aee898bea43a41450feeb655f94bde8277a4f9c18cab6b9d1accbb47
4
+ data.tar.gz: fe4806d8a2bfbb71496a720371cc8416b5283c9b8284c1a72ec46384ee234348
5
5
  SHA512:
6
- metadata.gz: 4e158fd8202e9c6e4f94a7b243383c34f2179306a10724978083b6599197197221915857540ae2beb5ea98a5f602c6507bec89d192a57706b3aae2203c75a8a3
7
- data.tar.gz: e9723bb6a0ef7b1a67db08799e3429ae4c2e9f88894afb9e614645673df7cb4e019107f0ca1339eb521fa7958fd259ca032385f98fdbddacd59415d960f0c347
6
+ metadata.gz: 43d06881f297512724588c06637a29eefcb8308fc1c086a09b013c4291d4ca58cf7b2ab482b1c1276a3e4a88544a71e95289268cc896d479cb92135f31555bf8
7
+ data.tar.gz: 9af4f8816208451c18ff3b31d7d5a7e230561af93753d1ee0f8177f515b92f3dd2c47007b7f65be823ceeb69b6338683a05c57eb1a13e17cfe769b87e4ef2540
data/.yardopts CHANGED
@@ -1 +1 @@
1
- --exclude lib/rage/templates --markup markdown --no-private -o doc
1
+ --exclude lib/rage/templates --exclude lib/rage/rspec --exclude lib/rage/rails --markup markdown --no-private -o doc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2024-03-13
4
+
5
+ ### Added
6
+
7
+ - RSpec integration (#60).
8
+ - Add DNS cache (#65).
9
+ - Allow to disable the `FiberScheduler#io_write` hook (#63).
10
+
11
+ ### Fixed
12
+
13
+ - Preload fiber ID (#62).
14
+ - Release ActiveRecord connections on yield (#66).
15
+ - Logger fixes (#64).
16
+ - Fix publish calls in cluster mode (#67).
17
+
18
+ ## [0.7.0] - 2024-01-09
19
+
20
+ - Add conditional GET using `stale?` by [@tonekk](https://github.com/tonekk) (#55).
21
+ - Add Rails integration (#57).
22
+ - Add JSON log formatter (#59).
23
+
3
24
  ## [0.6.0] - 2023-12-22
4
25
 
5
26
  ### 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
+ :white_check_mark: | ~~Support conditional get with `etag` and `last_modified`.~~
154
+ | Expose the `send_data` and `send_file` methods.
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
data/lib/rage/fiber.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Fiber
4
+ # @private
4
5
  AWAIT_ERROR_MESSAGE = "err"
5
6
 
6
7
  # @private
@@ -23,14 +24,14 @@ class Fiber
23
24
  @__err
24
25
  end
25
26
 
26
- # @private
27
- def __get_id
28
- @__rage_id ||= object_id.to_s
27
+ # @private
28
+ def __set_id
29
+ @__rage_id = object_id.to_s
29
30
  end
30
31
 
31
- # @private
32
- def __yielded?
33
- !@__rage_id.nil?
32
+ # @private
33
+ def __get_id
34
+ @__rage_id
34
35
  end
35
36
 
36
37
  # @private
@@ -49,6 +50,13 @@ class Fiber
49
50
  Fiber.yield
50
51
  end
51
52
 
53
+ # @private
54
+ # under normal circumstances, the method is a copy of `yield`, but it can be overriden to perform
55
+ # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/rails.rb"
56
+ class << self
57
+ alias_method :defer, :yield
58
+ end
59
+
52
60
  # Wait on several fibers at the same time. Calling this method will automatically pause the current fiber, allowing the
53
61
  # server to process other requests. Once all fibers have completed, the current fiber will be automatically resumed.
54
62
  #
@@ -7,13 +7,14 @@ class Rage::FiberScheduler
7
7
 
8
8
  def initialize
9
9
  @root_fiber = Fiber.current
10
+ @dns_cache = {}
10
11
  end
11
12
 
12
13
  def io_wait(io, events, timeout = nil)
13
14
  f = Fiber.current
14
15
  ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { |err| f.resume(err) }
15
16
 
16
- err = Fiber.yield
17
+ err = Fiber.defer
17
18
  if err == Errno::ETIMEDOUT::Errno
18
19
  0
19
20
  else
@@ -49,13 +50,15 @@ class Rage::FiberScheduler
49
50
  end
50
51
  end
51
52
 
52
- def io_write(io, buffer, length, offset = 0)
53
- bytes_to_write = length
54
- bytes_to_write = buffer.size if length == 0
53
+ unless ENV["RAGE_DISABLE_IO_WRITE"]
54
+ def io_write(io, buffer, length, offset = 0)
55
+ bytes_to_write = length
56
+ bytes_to_write = buffer.size if length == 0
55
57
 
56
- ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
58
+ ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
57
59
 
58
- bytes_to_write - offset
60
+ bytes_to_write - offset
61
+ end
59
62
  end
60
63
 
61
64
  def kernel_sleep(duration = nil)
@@ -77,7 +80,13 @@ class Rage::FiberScheduler
77
80
  # end
78
81
 
79
82
  def address_resolve(hostname)
80
- Resolv.getaddresses(hostname)
83
+ @dns_cache[hostname] ||= begin
84
+ ::Iodine.run_after(60_000) do
85
+ @dns_cache[hostname] = nil
86
+ end
87
+
88
+ Resolv.getaddresses(hostname)
89
+ end
81
90
  end
82
91
 
83
92
  def block(_blocker, timeout = nil)
@@ -100,7 +109,7 @@ class Rage::FiberScheduler
100
109
  end
101
110
 
102
111
  def unblock(_blocker, fiber)
103
- ::Iodine.publish(fiber.__block_channel, "")
112
+ ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS)
104
113
  end
105
114
 
106
115
  def fiber(&block)
@@ -109,6 +118,7 @@ class Rage::FiberScheduler
109
118
  fiber = if parent == @root_fiber
110
119
  # the fiber to wrap a request in
111
120
  Fiber.new(blocking: false) do
121
+ Fiber.current.__set_id
112
122
  Fiber.current.__set_result(block.call)
113
123
  end
114
124
  else
@@ -119,10 +129,10 @@ class Rage::FiberScheduler
119
129
  Thread.current[:rage_logger] = logger
120
130
  Fiber.current.__set_result(block.call)
121
131
  # send a message for `Fiber.await` to work
122
- Iodine.publish("await:#{parent.object_id}", "") if parent.alive?
132
+ Iodine.publish("await:#{parent.object_id}", "", Iodine::PubSub::PROCESS) if parent.alive?
123
133
  rescue Exception => e
124
134
  Fiber.current.__set_err(e)
125
- Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE) if parent.alive?
135
+ Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
126
136
  end
127
137
  end
128
138
 
@@ -0,0 +1,46 @@
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] || { tags: [], context: {} }
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
+ elsif tags.length == 0
33
+ tags_msg = "{\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
34
+ else
35
+ tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\""
36
+ i = 2
37
+ while i < tags.length
38
+ tags_msg << ",\"#{tags[i]}\""
39
+ i += 1
40
+ end
41
+ tags_msg << "],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
42
+ end
43
+
44
+ "#{tags_msg},#{context_msg}\"message\":\"#{message}\"}\n"
45
+ end
46
+ end
@@ -83,7 +83,7 @@ class Rage::Logger
83
83
  # Rage.logger.info "cache miss"
84
84
  # end
85
85
  def with_context(context)
86
- old_context = Thread.current[:rage_logger][:context]
86
+ old_context = (Thread.current[:rage_logger] ||= { tags: [], context: {} })[:context]
87
87
 
88
88
  if old_context.empty? # there's nothing in the context yet
89
89
  Thread.current[:rage_logger][:context] = context
@@ -92,8 +92,6 @@ class Rage::Logger
92
92
  end
93
93
 
94
94
  yield(self)
95
- true
96
-
97
95
  ensure
98
96
  Thread.current[:rage_logger][:context] = old_context
99
97
  end
@@ -106,17 +104,20 @@ class Rage::Logger
106
104
  # Rage.logger.info "success"
107
105
  # end
108
106
  def tagged(tag)
109
- Thread.current[:rage_logger][:tags] << tag
110
-
107
+ (Thread.current[:rage_logger] ||= { tags: [], context: {} })[:tags] << tag
111
108
  yield(self)
112
- true
113
-
114
109
  ensure
115
110
  Thread.current[:rage_logger][:tags].pop
116
111
  end
117
112
 
118
113
  alias_method :with_tag, :tagged
119
114
 
115
+ def debug? = @level <= Logger::DEBUG
116
+ def error? = @level <= Logger::ERROR
117
+ def fatal? = @level <= Logger::FATAL
118
+ def info? = @level <= Logger::INFO
119
+ def warn? = @level <= Logger::WARN
120
+
120
121
  private
121
122
 
122
123
  def define_log_methods
@@ -128,8 +129,8 @@ class Rage::Logger
128
129
  false
129
130
  end
130
131
  RUBY
131
- elsif defined?(IRB)
132
- # the call was made from IRB - don't use the formatter
132
+ elsif (Rage.config.internal.rails_mode ? Rage.config.internal.rails_console : defined?(IRB))
133
+ # the call was made from the console - don't use the formatter
133
134
  <<-RUBY
134
135
  def #{level_name}(msg = nil)
135
136
  @logdev.write((msg || yield) + "\n")
@@ -7,16 +7,21 @@ class Rage::TextFormatter
7
7
  end
8
8
 
9
9
  def call(severity, timestamp, _, message)
10
- logger = Thread.current[:rage_logger]
11
- tags = logger[:tags]
10
+ logger = Thread.current[:rage_logger] || { tags: [], context: {} }
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
 
@@ -24,6 +29,8 @@ class Rage::TextFormatter
24
29
  tags_msg = "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
25
30
  elsif tags.length == 2
26
31
  tags_msg = "[#{tags[0]}][#{tags[1]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
32
+ elsif tags.length == 0
33
+ tags_msg = "timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
27
34
  else
28
35
  tags_msg = "[#{tags[0]}][#{tags[1]}]"
29
36
  i = 2
@@ -34,13 +41,6 @@ class Rage::TextFormatter
34
41
  tags_msg << " timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
35
42
  end
36
43
 
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
44
  "#{tags_msg} #{context_msg}message=#{message}\n"
45
45
  end
46
46
  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 != "*"
@@ -17,7 +17,7 @@ class Rage::FiberWrapper
17
17
  @app.call(env)
18
18
  ensure
19
19
  # notify Iodine the request can now be resumed
20
- Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS) if Fiber.current.__yielded?
20
+ Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS)
21
21
  end
22
22
 
23
23
  # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request
data/lib/rage/rails.rb ADDED
@@ -0,0 +1,77 @@
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
+ # release ActiveRecord connections on yield
34
+ if defined?(ActiveRecord)
35
+ class Fiber
36
+ def self.defer
37
+ res = Fiber.yield
38
+
39
+ if ActiveRecord::Base.connection_pool.active_connection?
40
+ ActiveRecord::Base.connection_handler.clear_active_connections!
41
+ end
42
+
43
+ res
44
+ end
45
+ end
46
+ end
47
+
48
+ # plug into Rails' Zeitwerk instance to reload the code
49
+ Rails.autoloaders.main.on_setup do
50
+ if Iodine.running?
51
+ Rage.code_loader.rails_mode_reload
52
+ end
53
+ end
54
+
55
+ # patch `ActionDispatch::Reloader` to synchronize `reload!` calls
56
+ Rails.configuration.after_initialize do
57
+ conditional_mutex = Module.new do
58
+ def call(env)
59
+ @mutex ||= Mutex.new
60
+ if Rails.application.reloader.check!
61
+ @mutex.synchronize { super }
62
+ else
63
+ super
64
+ end
65
+ end
66
+ end
67
+
68
+ ActionDispatch::Reloader.prepend(conditional_mutex)
69
+ end
70
+
71
+ # clone Rails logger
72
+ Rails.configuration.after_initialize do
73
+ if Rails.logger && !Rage.logger
74
+ rails_logdev = Rails.logger.instance_variable_get(:@logdev)
75
+ Rage.config.logger = Rage::Logger.new(rails_logdev) if rails_logdev.is_a?(Logger::LogDevice)
76
+ end
77
+ 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/rspec.rb ADDED
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/test"
4
+ require "json"
5
+
6
+ # set up environment
7
+ ENV["RAGE_ENV"] ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test"
8
+
9
+ # load the app
10
+ require "bundler/setup"
11
+ require "rage"
12
+ require_relative "#{Rage.root}/config/application"
13
+
14
+ # verify the environment
15
+ abort("The test suite is running in #{Rage.env} mode instead of 'test'!") unless Rage.env.test?
16
+
17
+ # mock fiber methods as RSpec tests don't run concurrently
18
+ class Fiber
19
+ def self.schedule(&block)
20
+ fiber = Fiber.new(blocking: true) do
21
+ Fiber.current.__set_id
22
+ Fiber.current.__set_result(block.call)
23
+ end
24
+ fiber.resume
25
+
26
+ fiber
27
+ end
28
+
29
+ def self.await(_)
30
+ # no-op
31
+ end
32
+ end
33
+
34
+ # define request helpers
35
+ module RageRequestHelpers
36
+ include Rack::Test::Methods
37
+
38
+ alias_method :response, :last_response
39
+
40
+ APP = Rack::Builder.parse_file("#{Rage.root}/config.ru").yield_self do |app|
41
+ app.is_a?(Array) ? app[0] : app
42
+ end
43
+
44
+ def app
45
+ APP
46
+ end
47
+
48
+ %w(get options head).each do |method_name|
49
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
50
+ def #{method_name}(path, params: {}, headers: {})
51
+ request("#{method_name.upcase}", path, params: params, headers: headers)
52
+ end
53
+ RUBY
54
+ end
55
+
56
+ %w(post put patch delete).each do |method_name|
57
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
58
+ def #{method_name}(path, params: {}, headers: {}, as: nil)
59
+ if as == :json
60
+ params = params.to_json
61
+ headers["content-type"] = "application/json"
62
+ end
63
+
64
+ request("#{method_name.upcase}", path, params: params, headers: headers.merge("IODINE_HAS_BODY" => !params.empty?))
65
+ end
66
+ RUBY
67
+ end
68
+
69
+ def request(method, path, params: {}, headers: {})
70
+ if headers.any?
71
+ headers = headers.transform_keys do |k|
72
+ if k.downcase == "content-type"
73
+ "CONTENT_TYPE"
74
+ elsif k.downcase == "content-length"
75
+ "CONTENT_LENGTH"
76
+ elsif k.upcase == k
77
+ k
78
+ else
79
+ "HTTP_#{k.tr("-", "_").upcase! || k}"
80
+ end
81
+ end
82
+ end
83
+
84
+ custom_request(method, path, params, headers)
85
+ end
86
+
87
+ def host!(host)
88
+ @__host = host
89
+ end
90
+
91
+ def default_host
92
+ @__host || "example.org"
93
+ end
94
+ end
95
+
96
+ # include request helpers
97
+ RSpec.configure do |config|
98
+ config.include(RageRequestHelpers, type: :request)
99
+ end
100
+
101
+ # patch MockResponse class
102
+ class Rack::MockResponse
103
+ def parsed_body
104
+ if headers["content-type"].start_with?("application/json")
105
+ JSON.parse(body)
106
+ else
107
+ body
108
+ end
109
+ end
110
+
111
+ def code
112
+ status.to_s
113
+ end
114
+
115
+ alias_method :response_code, :status
116
+ end
117
+
118
+ # define http status matcher
119
+ RSpec::Matchers.matcher :have_http_status do |expected|
120
+ codes = Rack::Utils::SYMBOL_TO_STATUS_CODE
121
+
122
+ failure_message do |response|
123
+ actual = response.status
124
+
125
+ if expected.is_a?(Integer)
126
+ "expected the response to have status code #{expected} but it was #{actual}"
127
+ elsif expected == :success
128
+ "expected the response to have a success status code (2xx) but it was #{actual}"
129
+ elsif expected == :error
130
+ "expected the response to have an error status code (5xx) but it was #{actual}"
131
+ elsif expected == :missing
132
+ "expected the response to have a missing status code (404) but it was #{actual}"
133
+ else
134
+ "expected the response to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})"
135
+ end
136
+ end
137
+
138
+ failure_message_when_negated do |response|
139
+ actual = response.status
140
+
141
+ if expected.is_a?(Integer)
142
+ "expected the response not to have status code #{expected} but it was #{actual}"
143
+ elsif expected == :success
144
+ "expected the response not to have a success status code (2xx) but it was #{actual}"
145
+ elsif expected == :error
146
+ "expected the response not to have an error status code (5xx) but it was #{actual}"
147
+ elsif expected == :missing
148
+ "expected the response not to have a missing status code (404) but it was #{actual}"
149
+ else
150
+ "expected the response not to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})"
151
+ end
152
+ end
153
+
154
+ match do |response|
155
+ actual = response.status
156
+
157
+ case expected
158
+ when :success
159
+ actual >= 200 && actual < 300
160
+ when :error
161
+ actual >= 500
162
+ when :missing
163
+ actual == 404
164
+ when Symbol
165
+ actual == codes.fetch(expected)
166
+ else
167
+ actual == expected
168
+ end
169
+ end
170
+ end
171
+
172
+ if defined? RSpec::Rails::Matchers
173
+ module RSpec::Rails::Matchers
174
+ def have_http_status(_)
175
+ super
176
+ end
177
+ end
178
+ end
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "0.6.0"
4
+ VERSION = "1.0.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
data/rage.gemspec CHANGED
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "rack", "~> 2.0"
32
32
  spec.add_dependency "rage-iodine", "~> 3.0"
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
+ spec.add_dependency "rack-test", "~> 2.1"
34
35
  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: 1.0.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-03-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack-test
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.1'
69
83
  description:
70
84
  email:
71
85
  - rsamoi@icloud.com
@@ -95,13 +109,16 @@ files:
95
109
  - lib/rage/errors.rb
96
110
  - lib/rage/fiber.rb
97
111
  - lib/rage/fiber_scheduler.rb
112
+ - lib/rage/logger/json_formatter.rb
98
113
  - lib/rage/logger/logger.rb
99
114
  - lib/rage/logger/text_formatter.rb
100
115
  - lib/rage/middleware/cors.rb
101
116
  - lib/rage/middleware/fiber_wrapper.rb
102
117
  - lib/rage/middleware/reloader.rb
103
118
  - lib/rage/params_parser.rb
119
+ - lib/rage/rails.rb
104
120
  - lib/rage/request.rb
121
+ - lib/rage/response.rb
105
122
  - lib/rage/router/README.md
106
123
  - lib/rage/router/backend.rb
107
124
  - lib/rage/router/constrainer.rb
@@ -109,6 +126,7 @@ files:
109
126
  - lib/rage/router/handler_storage.rb
110
127
  - lib/rage/router/node.rb
111
128
  - lib/rage/router/strategies/host.rb
129
+ - lib/rage/rspec.rb
112
130
  - lib/rage/setup.rb
113
131
  - lib/rage/sidekiq_session.rb
114
132
  - lib/rage/templates/Gemfile