rage-rb 0.6.0 → 1.0.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: 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