rage-rb 0.4.0 → 0.5.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: adfca83c806d24e4baf6e3c6401c7268e7e88495eaee9120da3a55fca08400b8
4
- data.tar.gz: 7769fd22482975665a66741d0362ce5bf75318192bd6ee2850f98da952f5fe35
3
+ metadata.gz: 22f98c39badb0c2128b270ce3092d183f62db4bead34e8df627cf322a3697c71
4
+ data.tar.gz: ea49aeae3da7630f52e8c704bc042752a66b0fefc03a67d713ead33eec1fd006
5
5
  SHA512:
6
- metadata.gz: 66667fbd047a4c27ebb840f4fc6fd2f60fb50d90b7f25b38048afeb5a92b1ad6f5310a4f0eacbab42b2d11a0c0de44ff4a131d197737abe13f1817a7b2267147
7
- data.tar.gz: e75d8f5c615844fe837015ddaf7d2f9c4a96cdebc321ad63c79a3a1645924f2b3a7ef3fe75007243042a14bae18b001bd6cb0c919754719fb339ae42d0e90075
6
+ metadata.gz: e9e57e1eafda6e41112b8af0cc8d23eac629e0964b441622c48173f936f45aac95d0482b3545c5feb047dee755889c30ac63d0f63332e59d36cdff5cb1800198
7
+ data.tar.gz: 481b9e52062350a9923c3b0195aea555ca0b8cb0c95fa95e76d3e7cd45a27e21a817ad259aed6e1cf9d90c74c17e99b8398066e84deda7e422247a1ea59254b9
data/CHANGELOG.md CHANGED
@@ -1,25 +1,44 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2023-11-25
4
+
5
+ ### Added
6
+
7
+ - Add sessions for compatibility with `Sidekiq::Web` (#35).
8
+ - Add logger (#33).
9
+
10
+ ### Fixed
11
+
12
+ - Fixes for `FiberScheduler#io_wait` and `FiberScheduler#io_read` (#32).
13
+ - Correctly handle exceptions in inner fibers (#34).
14
+ - Fixes for `FiberScheduler#kernel_sleep` (#36).
15
+
16
+ ### Changed
17
+
18
+ - Use config namespaces (#25).
19
+ - Update `Fiber.await` signature (#36).
20
+
3
21
  ## [0.4.0] - 2023-10-31
4
22
 
5
23
  ### Added
6
24
 
7
- - Expose the `params` object.
8
- - Support header authentication with `authenticate_with_http_token`.
9
- - Add the `resources` and `namespace` route helpers.
10
- - Add the `mount` and `match` route helpers.
11
- - Allow to access request headers.
25
+ - Expose the `params` object (#23).
26
+ - Support header authentication with `authenticate_with_http_token` (#21).
27
+ - Add the `resources` route helper (#20).
28
+ - Add the `namespace` route helper by [@arikarim](https://github.com/arikarim) (#17).
29
+ - Add the `mount` and `match` route helpers by [@arikarim](https://github.com/arikarim) (#18) (#14).
30
+ - Allow to access request headers by [@arikarim](https://github.com/arikarim) (#15).
12
31
  - Support custom ports when starting the app with `rage s`.
13
32
 
14
33
  ## [0.3.0] - 2023-10-08
15
34
 
16
35
  ### Added
17
36
 
18
- - CLI `routes` task.
19
- - CLI `console` task.
20
- - `:if` and `:unless` options in `before_action`.
21
- - Allow to set response headers.
22
- - Block version of `before_action`.
37
+ - CLI `routes` task by [@arikarim](https://github.com/arikarim) (#9).
38
+ - CLI `console` task (#12).
39
+ - `:if` and `:unless` options in `before_action` (#10).
40
+ - Allow to set response headers (#11).
41
+ - Block version of `before_action` by [@heysyam99](https://github.com/heysyam99) (#8).
23
42
 
24
43
  ## [0.2.0] - 2023-09-27
25
44
 
data/Gemfile CHANGED
@@ -8,7 +8,9 @@ gemspec
8
8
  gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
+ gem "http"
11
12
  gem "yard"
12
13
 
13
14
  gem "pg"
14
15
  gem "mysql2"
16
+ gem "connection_pool", "~> 2.0"
data/README.md CHANGED
@@ -49,6 +49,7 @@ Check out in-depth API docs for more information:
49
49
  - [Controller API](https://rage-rb.pages.dev/RageController/API)
50
50
  - [Routing API](https://rage-rb.pages.dev/Rage/Router/DSL/Handler)
51
51
  - [Fiber API](https://rage-rb.pages.dev/Fiber)
52
+ - [Logger API](https://rage-rb.pages.dev/Rage/Logger)
52
53
 
53
54
  Also, see the [changelog](https://github.com/rage-rb/rage/blob/master/CHANGELOG.md) and [upcoming-releases](https://github.com/rage-rb/rage#upcoming-releases) for currently supported and planned features.
54
55
 
@@ -98,10 +99,10 @@ require "net/http"
98
99
 
99
100
  class PagesController < RageController::API
100
101
  def index
101
- pages = Fiber.await(
102
+ pages = Fiber.await([
102
103
  Fiber.schedule { Net::HTTP.get(URI("https://httpbin.org/json")) },
103
104
  Fiber.schedule { Net::HTTP.get(URI("https://httpbin.org/html")) },
104
- )
105
+ ])
105
106
 
106
107
  render json: { pages: pages }
107
108
  end
@@ -143,12 +144,13 @@ Version | Changes
143
144
  ------- |------------
144
145
  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;~~
145
146
  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.~~
146
- 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;<br>~~&emsp;• support regexp constraints (postponed)
147
- 0.5 | Implement Iodine-based equivalent of `ActionController::Live`.<br>Use `ActionDispatch::RemoteIp`.
148
- 0.6 | Expose the `cookies` object.<br>Expose the `send_data` and `send_file` methods.<br>Support conditional get with `etag` and `last_modified`.
149
- 0.7 | Add request logging.
150
- 0.8 | Collect app metrics.
151
- 0.9 | Automatic code reloading in development.
147
+ 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;~~
148
+ 0.5 :white_check_mark: | ~~Add request logging.~~
149
+ 0.6 | Automatic code reloading in development with Zeitwerk.
150
+ 0.7 | Expose the `send_data` and `send_file` methods.
151
+ 0.8 | Support conditional get with `etag` and `last_modified`.
152
+ 0.9 | Expose the `cookies` and `session` objects.
153
+ 1.0 | Implement Iodine-based equivalent of Action Cable.
152
154
 
153
155
  ## Development
154
156
 
data/lib/rage/all.rb CHANGED
@@ -18,3 +18,10 @@ require_relative "router/handler_storage"
18
18
  require_relative "router/node"
19
19
 
20
20
  require_relative "controller/api"
21
+
22
+ require_relative "logger/text_formatter"
23
+ require_relative "logger/logger"
24
+
25
+ if defined?(Sidekiq)
26
+ require_relative "sidekiq_session"
27
+ end
@@ -10,21 +10,25 @@ class Rage::Application
10
10
 
11
11
  def call(env)
12
12
  fiber = Fiber.schedule do
13
+ init_logger
14
+
13
15
  handler = @router.lookup(env)
14
16
 
15
- if handler
17
+ response = if handler
16
18
  params = Rage::ParamsParser.prepare(env, handler[:params])
17
19
  handler[:handler].call(env, params)
18
20
  else
19
21
  [404, {}, ["Not Found"]]
20
22
  end
21
23
 
22
- rescue => e
23
- [500, {}, ["#{e.class}:#{e.message}\n\n#{e.backtrace.join("\n")}"]]
24
+ rescue Exception => e
25
+ exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
26
+ Rage.logger.error(exception_str)
27
+ response = [500, {}, [exception_str]]
24
28
 
25
29
  ensure
26
- # notify Iodine the request can now be served
27
- Iodine.publish(env["IODINE_REQUEST_ID"], "")
30
+ finalize_logger(env, response, params)
31
+ Iodine.publish(env["IODINE_REQUEST_ID"], "") # notify Iodine the request can now be served
28
32
  end
29
33
 
30
34
  # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request;
@@ -34,4 +38,28 @@ class Rage::Application
34
38
  fiber.__get_result
35
39
  end
36
40
  end
41
+
42
+ private
43
+
44
+ DEFAULT_LOG_CONTEXT = {}.freeze
45
+ private_constant :DEFAULT_LOG_CONTEXT
46
+
47
+ def init_logger
48
+ Thread.current[:rage_logger] = {
49
+ tags: [Iodine::Rack::Utils.gen_request_tag],
50
+ context: DEFAULT_LOG_CONTEXT,
51
+ request_start: Process.clock_gettime(Process::CLOCK_MONOTONIC)
52
+ }
53
+ end
54
+
55
+ def finalize_logger(env, response, params)
56
+ logger = Thread.current[:rage_logger]
57
+
58
+ duration = (
59
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger[:request_start]) * 1000
60
+ ).round(2)
61
+
62
+ logger[:final] = { env:, params:, response:, duration: }
63
+ Rage.logger.info("")
64
+ end
37
65
  end
data/lib/rage/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require "thor"
3
- require "rage/all"
4
- require "irb"
3
+ require "rack"
5
4
 
6
5
  module Rage
7
6
  class CLI < Thor
@@ -11,6 +10,7 @@ module Rage
11
10
 
12
11
  desc "new PATH", "Create a new application."
13
12
  def new(path)
13
+ require "rage/all"
14
14
  NewAppGenerator.start([path])
15
15
  end
16
16
 
@@ -20,9 +20,9 @@ module Rage
20
20
  app = ::Rack::Builder.parse_file("config.ru")
21
21
  app = app[0] if app.is_a?(Array)
22
22
 
23
- ::Iodine.listen service: :http, handler: app, port: options[:port] || Rage.config.port
24
- ::Iodine.threads = Rage.config.threads_count
25
- ::Iodine.workers = Rage.config.workers_count
23
+ ::Iodine.listen service: :http, handler: app, port: options[:port] || Rage.config.server.port
24
+ ::Iodine.threads = Rage.config.server.threads_count
25
+ ::Iodine.workers = Rage.config.server.workers_count
26
26
 
27
27
  ::Iodine.start
28
28
  end
@@ -39,14 +39,20 @@ module Rage
39
39
 
40
40
  routes = Rage.__router.routes
41
41
  pattern = options[:grep]
42
- routes.unshift({ method: "Verb", path: "Path", raw_handler: "Controller#Action" })
42
+ routes.unshift({ method: "Verb", path: "Path", meta: { raw_handler: "Controller#Action" } })
43
43
 
44
44
  grouped_routes = routes.each_with_object({}) do |route, memo|
45
45
  if pattern && !memo.empty?
46
- next unless route[:path].match?(pattern) || route[:raw_handler].to_s.match?(pattern) || route[:method].match?(pattern)
46
+ next unless route[:path].match?(pattern) || route[:meta][:raw_handler].to_s.match?(pattern) || route[:method].match?(pattern)
47
+ end
48
+
49
+ key = [route[:path], route[:meta][:raw_handler]]
50
+
51
+ if route[:meta][:mount]
52
+ memo[key] = route.merge(method: "") unless route[:path].end_with?("*")
53
+ next
47
54
  end
48
55
 
49
- key = [route[:path], route[:raw_handler]]
50
56
  if memo[key]
51
57
  memo[key][:method] += "|#{route[:method]}"
52
58
  else
@@ -68,7 +74,7 @@ module Rage
68
74
  meta = route[:constraints]
69
75
  meta.merge!(route[:defaults]) if route[:defaults]
70
76
 
71
- handler = route[:raw_handler]
77
+ handler = route[:meta][:raw_handler]
72
78
  handler = "#{handler} #{meta}" unless meta&.empty?
73
79
 
74
80
  puts format("%-#{longest_method}s%-#{longest_path}s%s", route[:method], route[:path], handler)
@@ -78,6 +84,7 @@ module Rage
78
84
 
79
85
  desc "c", "Start the app console."
80
86
  def console
87
+ require "irb"
81
88
  environment
82
89
  ARGV.clear
83
90
  IRB.start
@@ -1,10 +1,26 @@
1
1
  class Rage::Configuration
2
- attr_accessor :port, :workers_count
3
- attr_reader :threads_count
2
+ attr_accessor :logger, :log_formatter, :log_level
4
3
 
5
- def initialize
6
- @threads_count = 1
7
- @workers_count = -1
8
- @port = 3000
4
+ # used in DSL
5
+ def config = self
6
+
7
+ def server
8
+ @server ||= Server.new
9
+ end
10
+
11
+ class Server
12
+ attr_accessor :port, :workers_count
13
+ attr_reader :threads_count
14
+
15
+ def initialize
16
+ @threads_count = 1
17
+ @workers_count = -1
18
+ @port = 3000
19
+ end
20
+ end
21
+
22
+ def __finalize
23
+ @logger.formatter = @log_formatter if @logger && @log_formatter
24
+ @logger.level = @log_level if @logger && @log_level
9
25
  end
10
26
  end
@@ -50,7 +50,7 @@ class RageController::API
50
50
  ""
51
51
  end
52
52
 
53
- class_eval <<-RUBY
53
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
54
54
  def __run_#{action}
55
55
  #{before_actions_chunk}
56
56
  #{action}
data/lib/rage/fiber.rb CHANGED
@@ -1,4 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Fiber
4
+ AWAIT_ERROR_MESSAGE = "err"
5
+
2
6
  # @private
3
7
  def __set_result(result)
4
8
  @__result = result
@@ -9,30 +13,74 @@ class Fiber
9
13
  @__result
10
14
  end
11
15
 
16
+ # @private
17
+ def __set_err(err)
18
+ @__err = err
19
+ end
20
+
21
+ # @private
22
+ def __get_err
23
+ @__err
24
+ end
25
+
26
+ # @private
27
+ # pause a fiber and resume in the next iteration of the event loop
28
+ def self.pause
29
+ f = Fiber.current
30
+ Iodine.defer { f.resume }
31
+ Fiber.yield
32
+ end
33
+
12
34
  # Wait on several fibers at the same time. Calling this method will automatically pause the current fiber, allowing the
13
35
  # server to process other requests. Once all fibers have completed, the current fiber will be automatically resumed.
14
36
  #
15
37
  # @param fibers [Fiber, Array<Fiber>] one or several fibers to wait on. The fibers must be created using the `Fiber.schedule` call.
16
38
  # @example
17
- # Fiber.await(
39
+ # Fiber.await([
18
40
  # Fiber.schedule { request_1 },
19
41
  # Fiber.schedule { request_2 },
20
- # )
42
+ # ])
21
43
  # @note This method should only be used when multiple fibers have to be processed in parallel. There's no need to use `Fiber.await` for single IO calls.
22
- def self.await(*fibers)
23
- f = Fiber.current
44
+ def self.await(fibers)
45
+ f, fibers = Fiber.current, Array(fibers)
24
46
 
25
- num_wait_for = fibers.count(&:alive?)
26
- return fibers.map(&:__get_result) if num_wait_for == 0
47
+ # check which fibers are alive (i.e. have yielded) and which have errored out
48
+ i, err, num_wait_for = 0, nil, 0
49
+ while i < fibers.length
50
+ if fibers[i].alive?
51
+ num_wait_for += 1
52
+ else
53
+ err = fibers[i].__get_err
54
+ break if err
55
+ end
56
+ i += 1
57
+ end
58
+
59
+ # raise if one of the fibers has errored out or return the result if none have yielded
60
+ if err
61
+ raise err
62
+ elsif num_wait_for == 0
63
+ return fibers.map!(&:__get_result)
64
+ end
27
65
 
28
- Iodine.subscribe("await:#{f.object_id}") do
29
- num_wait_for -= 1
30
- f.resume if num_wait_for == 0
66
+ # wait on async fibers; resume right away if one of the fibers errors out
67
+ Iodine.subscribe("await:#{f.object_id}") do |_, err|
68
+ if err == AWAIT_ERROR_MESSAGE
69
+ f.resume
70
+ else
71
+ num_wait_for -= 1
72
+ f.resume if num_wait_for == 0
73
+ end
31
74
  end
32
75
 
33
76
  Fiber.yield
34
77
  Iodine.defer { Iodine.unsubscribe("await:#{f.object_id}") }
35
78
 
36
- fibers.map(&:__get_result)
79
+ # if num_wait_for is not 0 means we exited prematurely because of an error
80
+ if num_wait_for > 0
81
+ raise fibers.find(&:__get_err).__get_err
82
+ else
83
+ fibers.map!(&:__get_result)
84
+ end
37
85
  end
38
86
  end
@@ -3,41 +3,50 @@
3
3
  require "resolv"
4
4
 
5
5
  class Rage::FiberScheduler
6
+ MAX_READ = 65536
7
+
6
8
  def initialize
7
9
  @root_fiber = Fiber.current
8
10
  end
9
11
 
10
12
  def io_wait(io, events, timeout = nil)
11
13
  f = Fiber.current
12
- ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { f.resume }
13
- Fiber.yield
14
+ ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { |err| f.resume(err) }
14
15
 
15
- events
16
+ err = Fiber.yield
17
+ if err == Errno::ETIMEDOUT::Errno
18
+ 0
19
+ else
20
+ events
21
+ end
16
22
  end
17
23
 
18
- # TODO: this is more synchronous than asynchronous right now
19
24
  def io_read(io, buffer, length, offset = 0)
20
- loop do
21
- string = ::Iodine::Scheduler.read(io.fileno, length, offset)
25
+ length_to_read = if length == 0
26
+ buffer.size > MAX_READ ? MAX_READ : buffer.size
27
+ else
28
+ length
29
+ end
30
+
31
+ while true
32
+ string = ::Iodine::Scheduler.read(io.fileno, length_to_read, offset)
22
33
 
23
34
  if string.nil?
24
35
  return offset
25
36
  end
26
37
 
27
38
  if string.empty?
28
- io_wait(io, IO::READABLE)
29
- next
39
+ return -Errno::EAGAIN::Errno
30
40
  end
31
41
 
32
42
  buffer.set_string(string, offset)
33
- offset += string.bytesize
34
43
 
35
44
  size = string.bytesize
36
- break if size >= length
37
- length -= size
38
- end
45
+ offset += size
46
+ return offset if size < length_to_read || size >= buffer.size
39
47
 
40
- offset
48
+ Fiber.pause
49
+ end
41
50
  end
42
51
 
43
52
  def io_write(io, buffer, length, offset = 0)
@@ -46,15 +55,11 @@ class Rage::FiberScheduler
46
55
 
47
56
  ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
48
57
 
49
- buffer.size - offset
58
+ bytes_to_write - offset
50
59
  end
51
60
 
52
61
  def kernel_sleep(duration = nil)
53
- if duration
54
- f = Fiber.current
55
- ::Iodine.run_after((duration * 1000).to_i) { f.resume }
56
- Fiber.yield
57
- end
62
+ block(nil, duration || 0)
58
63
  end
59
64
 
60
65
  # TODO: GC works a little strange with this closure;
@@ -75,13 +80,22 @@ class Rage::FiberScheduler
75
80
  Resolv.getaddresses(hostname)
76
81
  end
77
82
 
78
- def block(blocker, timeout = nil)
79
- f = Fiber.current
80
- ::Iodine.subscribe("unblock:#{f.object_id}") do
81
- ::Iodine.defer { ::Iodine.unsubscribe("unblock:#{f.object_id}") }
82
- f.resume
83
+ def block(_blocker, timeout = nil)
84
+ f, fulfilled, channel = Fiber.current, false, "unblock:#{Fiber.current.object_id}"
85
+
86
+ resume_fiber_block = proc do
87
+ unless fulfilled
88
+ fulfilled = true
89
+ ::Iodine.defer { ::Iodine.unsubscribe(channel) }
90
+ f.resume
91
+ end
83
92
  end
84
- # TODO support timeout
93
+
94
+ ::Iodine.subscribe(channel, &resume_fiber_block)
95
+ if timeout
96
+ ::Iodine.run_after((timeout * 1000).to_i, &resume_fiber_block)
97
+ end
98
+
85
99
  Fiber.yield
86
100
  end
87
101
 
@@ -90,15 +104,28 @@ class Rage::FiberScheduler
90
104
  end
91
105
 
92
106
  def fiber(&block)
93
- f = Fiber.current
94
- inner_schedule = f != @root_fiber
107
+ parent = Fiber.current
95
108
 
96
- fiber = Fiber.new(blocking: false) do
97
- Fiber.current.__set_result(block.call)
98
- ensure
99
- # send a message for `Fiber.await` to work
100
- Iodine.publish("await:#{f.object_id}", "") if inner_schedule
109
+ fiber = if parent == @root_fiber
110
+ # the fiber to wrap a request in
111
+ Fiber.new(blocking: false) do
112
+ Fiber.current.__set_result(block.call)
113
+ end
114
+ else
115
+ # the fiber was created in the user code
116
+ logger = Thread.current[:rage_logger]
117
+
118
+ Fiber.new(blocking: false) do
119
+ Thread.current[:rage_logger] = logger
120
+ Fiber.current.__set_result(block.call)
121
+ # send a message for `Fiber.await` to work
122
+ Iodine.publish("await:#{parent.object_id}", "") if parent.alive?
123
+ rescue Exception => e
124
+ Fiber.current.__set_err(e)
125
+ Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE) if parent.alive?
126
+ end
101
127
  end
128
+
102
129
  fiber.resume
103
130
 
104
131
  fiber
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ ##
6
+ # All logs in `rage` consist of two parts: keys and tags. A sample log entry might look like this:
7
+ # ```
8
+ # [fecbba0735355738] timestamp=2023-10-19T11:12:56+00:00 pid=1825 level=info message=hello
9
+ # ```
10
+ # In the log entry above, `timestamp`, `pid`, `level`, and `message` are keys, while `fecbba0735355738` is a tag.
11
+ #
12
+ # Use {tagged} to add custom tags to an entry:
13
+ # ```ruby
14
+ # Rage.logger.tagged("ApiCall") do
15
+ # perform_api_call
16
+ # Rage.logger.info "success"
17
+ # end
18
+ # # => [fecbba0735355738][ApiCall] timestamp=2023-10-19T11:12:56+00:00 pid=1825 level=info message=success
19
+ # ```
20
+ #
21
+ # {with_context} can be used to add custom keys:
22
+ # ```ruby
23
+ # cache_key = "mykey"
24
+ # Rage.logger.with_context(cache_key: cache_key) do
25
+ # get_from_cache(cache_key)
26
+ # Rage.logger.info "cache miss"
27
+ # end
28
+ # # => [fecbba0735355738] timestamp=2023-10-19T11:12:56+00:00 pid=1825 level=info cache_key=mykey message=cache miss
29
+ # ```
30
+ #
31
+ # `Rage::Logger` also implements the interface of Ruby's native {https://ruby-doc.org/3.2.2/stdlibs/logger/Logger.html Logger}:
32
+ # ```ruby
33
+ # Rage.logger.info("Initializing")
34
+ # Rage.logger.debug { "This is a " + potentially + " expensive operation" }
35
+ # ```
36
+ class Rage::Logger
37
+ METHODS_MAP = {
38
+ "debug" => Logger::DEBUG,
39
+ "info" => Logger::INFO,
40
+ "warn" => Logger::WARN,
41
+ "error" => Logger::ERROR,
42
+ "fatal" => Logger::FATAL,
43
+ "unknown" => Logger::UNKNOWN
44
+ }
45
+ private_constant :METHODS_MAP
46
+
47
+ attr_reader :level, :formatter
48
+
49
+ # Create a new logger.
50
+ #
51
+ # @param log [Object] a filename (`String`), IO object (typically `STDOUT`, `STDERR`, or an open file), `nil` (it writes nothing) or `File::NULL` (same as `nil`)
52
+ # @param level [Integer] logging severity threshold
53
+ # @param formatter [#call] logging formatter
54
+ # @param shift_age [Integer, String] number of old log files to keep, or frequency of rotation (`"daily"`, `"weekly"` or `"monthly"`). Default value is `0`, which disables log file rotation
55
+ # @param shift_size [Integer] maximum log file size in bytes (only applies when `shift_age` is a positive Integer)
56
+ # @param shift_period_suffix [String] the log file suffix format for daily, weekly or monthly rotation
57
+ # @param binmode sets whether the logger writes in binary mode
58
+ def initialize(log, level: Logger::DEBUG, formatter: Rage::TextFormatter.new, shift_age: 0, shift_size: 104857600, shift_period_suffix: "%Y%m%d", binmode: false)
59
+ if log && log != File::NULL
60
+ @logdev = Logger::LogDevice.new(log, shift_age:, shift_size:, shift_period_suffix:, binmode:)
61
+ end
62
+
63
+ @formatter = formatter
64
+ @level = level
65
+ define_log_methods
66
+ end
67
+
68
+ def level=(level)
69
+ @level = level
70
+ define_log_methods
71
+ end
72
+
73
+ def formatter=(formatter)
74
+ @formatter = formatter
75
+ define_log_methods
76
+ end
77
+
78
+ # Add custom keys to an entry.
79
+ #
80
+ # @param context [Hash] a hash of custom keys
81
+ # @example
82
+ # Rage.logger.with_context(key: "mykey") do
83
+ # Rage.logger.info "cache miss"
84
+ # end
85
+ def with_context(context)
86
+ old_context = Thread.current[:rage_logger][:context]
87
+
88
+ if old_context.empty? # there's nothing in the context yet
89
+ Thread.current[:rage_logger][:context] = context
90
+ else # it's not the first `with_context` call in the chain
91
+ Thread.current[:rage_logger][:context] = old_context.merge(context)
92
+ end
93
+
94
+ yield(self)
95
+ true
96
+
97
+ ensure
98
+ Thread.current[:rage_logger][:context] = old_context
99
+ end
100
+
101
+ # Add a custom tag to an entry.
102
+ #
103
+ # @param tag [String] the tag to add to an entry
104
+ # @example
105
+ # Rage.logger.tagged("ApiCall") do
106
+ # Rage.logger.info "success"
107
+ # end
108
+ def tagged(tag)
109
+ Thread.current[:rage_logger][:tags] << tag
110
+
111
+ yield(self)
112
+ true
113
+
114
+ ensure
115
+ Thread.current[:rage_logger][:tags].pop
116
+ end
117
+
118
+ alias_method :with_tag, :tagged
119
+
120
+ private
121
+
122
+ def define_log_methods
123
+ methods = METHODS_MAP.map do |level_name, level_val|
124
+ if @logdev.nil? || level_val < @level
125
+ # logging is disabled or the log level is higher than the current one
126
+ <<-RUBY
127
+ def #{level_name}(msg = nil)
128
+ false
129
+ end
130
+ RUBY
131
+ elsif defined?(IRB)
132
+ # the call was made from IRB - don't use the formatter
133
+ <<-RUBY
134
+ def #{level_name}(msg = nil)
135
+ @logdev.write((msg || yield) + "\n")
136
+ end
137
+ RUBY
138
+ elsif @formatter.class.name.start_with?("Rage::")
139
+ # the call was made from within the application and a built-in formatter is used;
140
+ # in such case we use the `gen_timestamp` method which is much faster than `Time.now.strftime`;
141
+ # it's not a standard approach however, so it's used with built-in formatters only
142
+ <<-RUBY
143
+ def #{level_name}(msg = nil)
144
+ @logdev.write(
145
+ @formatter.call("#{level_name}".freeze, Iodine::Rack::Utils.gen_timestamp, nil, msg || yield)
146
+ )
147
+ end
148
+ RUBY
149
+ else
150
+ # the call was made from within the application and a custom formatter is used;
151
+ # stick to the standard approach of using one of the Log Level constants as sevetiry and `Time.now` as time
152
+ <<-RUBY
153
+ def #{level_name}(msg = nil)
154
+ @logdev.write(
155
+ @formatter.call(#{level_val}, Time.now, nil, msg || yield)
156
+ )
157
+ end
158
+ RUBY
159
+ end
160
+ end
161
+
162
+ self.class.class_eval(methods.join("\n"))
163
+ end
164
+ end
@@ -0,0 +1,46 @@
1
+ class Rage::TextFormatter
2
+ def initialize
3
+ @pid = Process.pid
4
+ Iodine.on_state(:on_start) do
5
+ @pid = Process.pid
6
+ end
7
+ end
8
+
9
+ def call(severity, timestamp, _, message)
10
+ logger = Thread.current[:rage_logger]
11
+ tags = logger[:tags]
12
+
13
+ if final = logger[:final]
14
+ params, env = final[:params], final[:env]
15
+ 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"
17
+ else
18
+ # 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"
20
+ end
21
+ end
22
+
23
+ if tags.length == 1
24
+ tags_msg = "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
25
+ elsif tags.length == 2
26
+ tags_msg = "[#{tags[0]}][#{tags[1]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
27
+ else
28
+ tags_msg = "[#{tags[0]}][#{tags[1]}]"
29
+ i = 2
30
+ while i < tags.length
31
+ tags_msg << "[#{tags[i]}]"
32
+ i += 1
33
+ end
34
+ tags_msg << " timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
35
+ end
36
+
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
+ "#{tags_msg} #{context_msg}message=#{message}\n"
45
+ end
46
+ end
@@ -14,8 +14,36 @@ class Rage::Router::Backend
14
14
  @constrainer = Rage::Router::Constrainer.new({})
15
15
  end
16
16
 
17
- def on(method, path, handler, constraints: {}, defaults: nil)
17
+ def mount(path, handler, methods)
18
+ raise "Mount handler should respond to `call`" unless handler.respond_to?(:call)
19
+
18
20
  raw_handler = handler
21
+ is_sidekiq = handler.respond_to?(:name) && handler.name == "Sidekiq::Web"
22
+
23
+ handler = ->(env, _params) do
24
+ env["SCRIPT_NAME"] = path
25
+ sub_path = env["PATH_INFO"].delete_prefix!(path)
26
+ env["PATH_INFO"] = "/" if sub_path == ""
27
+
28
+ if is_sidekiq
29
+ Rage::SidekiqSession.with_session(env) do
30
+ raw_handler.call(env)
31
+ end
32
+ else
33
+ raw_handler.call(env)
34
+ end
35
+
36
+ ensure
37
+ env["PATH_INFO"] = "#{env["SCRIPT_NAME"]}#{sub_path}"
38
+ end
39
+
40
+ methods.each do |method|
41
+ __on(method, path, handler, {}, {}, { raw_handler:, mount: true })
42
+ __on(method, "#{path}/*", handler, {}, {}, { raw_handler:, mount: true })
43
+ end
44
+ end
45
+
46
+ def on(method, path, handler, constraints: {}, defaults: nil)
19
47
  raise "Path could not be empty" if path&.empty?
20
48
 
21
49
  if match_index = (path =~ OPTIONAL_PARAM_REGEXP)
@@ -29,12 +57,17 @@ class Rage::Router::Backend
29
57
  return
30
58
  end
31
59
 
60
+ meta = { raw_handler: handler }
61
+
32
62
  if handler.is_a?(String)
33
63
  raise "Invalid route handler format, expected to match the 'controller#action' pattern" unless handler =~ STRING_HANDLER_REGEXP
34
64
 
35
65
  controller, action = to_controller_class($1), $2
36
66
  run_action_method_name = controller.__register_action(action.to_sym)
37
67
 
68
+ meta[:controller] = $1
69
+ meta[:action] = $2
70
+
38
71
  handler = eval("->(env, params) { #{controller}.new(env, params).#{run_action_method_name} }")
39
72
  else
40
73
  raise "Non-string route handler should respond to `call`" unless handler.respond_to?(:call)
@@ -45,7 +78,7 @@ class Rage::Router::Backend
45
78
  handler = ->(env, _params) { orig_handler.call(env) }
46
79
  end
47
80
 
48
- __on(method, path, handler, raw_handler, constraints, defaults)
81
+ __on(method, path, handler, constraints, defaults, meta)
49
82
  end
50
83
 
51
84
  def lookup(env)
@@ -55,7 +88,7 @@ class Rage::Router::Backend
55
88
 
56
89
  private
57
90
 
58
- def __on(method, path, handler, raw_handler, constraints, defaults)
91
+ def __on(method, path, handler, constraints, defaults, meta)
59
92
  @constrainer.validate_constraints(constraints)
60
93
  # Let the constrainer know if any constraints are being used now
61
94
  @constrainer.note_usage(constraints)
@@ -162,7 +195,7 @@ class Rage::Router::Backend
162
195
  end
163
196
  end
164
197
 
165
- route = { method: method, path: path, pattern: pattern, params: params, constraints: constraints, handler: handler, raw_handler: raw_handler, defaults: defaults }
198
+ route = { method:, path:, pattern:, params:, constraints:, handler:, defaults:, meta: }
166
199
  @routes << route
167
200
  current_node.add_route(route, @constrainer)
168
201
  end
@@ -12,7 +12,7 @@ class Rage::Router::DSL
12
12
  ##
13
13
  # This class implements routing logic for your application, providing API similar to Rails.
14
14
  #
15
- # Compared to Rails router, the most notable difference is that a wildcard segment can only be in the last section of the path and cannot be named.
15
+ # Compared to the Rails router, the most notable difference is that a wildcard segment can only be in the last section of the path and cannot be named.
16
16
  # Example:
17
17
  # ```ruby
18
18
  # get "/photos/*"
@@ -327,8 +327,19 @@ class Rage::Router::DSL
327
327
  via = args[1][:via]
328
328
  end
329
329
 
330
- # Use match with via: :all to mount the Rack-based application
331
- match(at, to: app, via:)
330
+ at = "/#{at}" unless at.start_with?("/")
331
+ at = at.delete_suffix("/") if at.end_with?("/")
332
+
333
+ http_methods = if via == :all || via.nil?
334
+ @default_match_methods.map { |method| method.to_s.upcase! }
335
+ else
336
+ Array(via).map! do |method|
337
+ raise ArgumentError, "Invalid HTTP method: #{method}" unless @default_match_methods.include?(method)
338
+ method.to_s.upcase!
339
+ end
340
+ end
341
+
342
+ @router.mount(at, app, http_methods)
332
343
  end
333
344
 
334
345
  private
@@ -22,7 +22,7 @@ class Rage::Router::HandlerStorage
22
22
  params: params,
23
23
  constraints: constraints,
24
24
  handler: route[:handler],
25
- create_params_object: compile_create_params_object(params, route[:defaults])
25
+ create_params_object: compile_create_params_object(params, route[:defaults], route[:meta])
26
26
  }
27
27
 
28
28
  constraints_keys = constraints.keys
@@ -47,8 +47,11 @@ class Rage::Router::HandlerStorage
47
47
 
48
48
  private
49
49
 
50
- def compile_create_params_object(param_keys, defaults)
51
- lines = []
50
+ def compile_create_params_object(param_keys, defaults, meta)
51
+ lines = [
52
+ ":controller => '#{meta[:controller]}'.freeze",
53
+ ":action => '#{meta[:action]}'.freeze"
54
+ ]
52
55
 
53
56
  param_keys.each_with_index do |key, i|
54
57
  lines << ":#{key} => param_values[#{i}]"
@@ -56,7 +59,7 @@ class Rage::Router::HandlerStorage
56
59
 
57
60
  if defaults
58
61
  defaults.except(*param_keys.map(&:to_sym)).each do |key, value|
59
- lines << ":#{key} => '#{value}'"
62
+ lines << ":#{key} => '#{value}'.freeze"
60
63
  end
61
64
  end
62
65
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "base64"
5
+
6
+ ##
7
+ # Used **specifically** for compatibility with Sidekiq's Web interface.
8
+ # Remove once we have real sessions or once Sidekiq's author decides they
9
+ # don't need cookie sessions to protect against CSRF.
10
+ #
11
+ class Rage::SidekiqSession
12
+ KEY = Digest::SHA2.hexdigest(ENV["SECRET_KEY_BASE"] || File.read("Gemfile.lock") + File.read("config/routes.rb"))
13
+ SESSION_KEY = "rage.sidekiq.session"
14
+
15
+ def self.with_session(env)
16
+ env["rack.session"] = session = self.new(env)
17
+ response = yield
18
+
19
+ if session.changed
20
+ Rack::Utils.set_cookie_header!(
21
+ response[1],
22
+ SESSION_KEY,
23
+ { path: env["SCRIPT_NAME"], httponly: true, same_site: true, value: session.dump }
24
+ )
25
+ end
26
+
27
+ response
28
+ end
29
+
30
+ attr_reader :changed
31
+
32
+ def initialize(env)
33
+ @env = env
34
+ session = Rack::Utils.parse_cookies(@env)[SESSION_KEY]
35
+ @data = decode_session(session)
36
+ end
37
+
38
+ def [](key)
39
+ @data[key]
40
+ end
41
+
42
+ def[]=(key, value)
43
+ @changed = true
44
+ @data[key] = value
45
+ end
46
+
47
+ def to_hash
48
+ @data
49
+ end
50
+
51
+ def dump
52
+ encoded_data = Marshal.dump(@data)
53
+ signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data)
54
+
55
+ Base64.urlsafe_encode64("#{encoded_data}--#{signature}")
56
+ end
57
+
58
+ private
59
+
60
+ def decode_session(session)
61
+ return {} unless session
62
+
63
+ encoded_data, signature = Base64.urlsafe_decode64(session).split("--")
64
+ ref_signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data)
65
+
66
+ if Rack::Utils.secure_compare(signature, ref_signature)
67
+ Marshal.load(encoded_data)
68
+ else
69
+ {}
70
+ end
71
+ end
72
+ end
@@ -0,0 +1 @@
1
+ require_relative "config/application"
@@ -1,6 +1,11 @@
1
1
  require "bundler/setup"
2
+ require "rage"
2
3
  Bundler.require(*Rage.groups)
3
4
 
4
5
  require "rage/all"
5
6
 
7
+ Rage.configure do
8
+ # use this to add settings that are constant across all environments
9
+ end
10
+
6
11
  require "rage/setup"
@@ -1,7 +1,10 @@
1
- Rage.configure do |config|
1
+ Rage.configure do
2
2
  # Specify the number of server processes to run. Defaults to number of CPU cores.
3
- config.workers_count = 1
3
+ config.server.workers_count = 1
4
4
 
5
5
  # Specify the port the server will listen on.
6
- config.port = 3000
6
+ config.server.port = 3000
7
+
8
+ # Specify the logger
9
+ config.logger = Rage::Logger.new(STDOUT)
7
10
  end
@@ -1,7 +1,11 @@
1
- Rage.configure do |config|
1
+ Rage.configure do
2
2
  # Specify the number of server processes to run. Defaults to number of CPU cores.
3
- # config.workers_count = ENV.fetch("WEB_CONCURRENCY", 1)
3
+ # config.server.workers_count = ENV.fetch("WEB_CONCURRENCY", 1)
4
4
 
5
5
  # Specify the port the server will listen on.
6
- config.port = 3000
6
+ config.server.port = 3000
7
+
8
+ # Specify the logger
9
+ config.logger = Rage::Logger.new("log/production.log")
10
+ config.log_level = Logger::INFO
7
11
  end
@@ -1,7 +1,10 @@
1
- Rage.configure do |config|
1
+ Rage.configure do
2
2
  # Specify the number of server processes to run. Defaults to number of CPU cores.
3
- config.workers_count = 1
3
+ config.server.workers_count = 1
4
4
 
5
5
  # Specify the port the server will listen on.
6
- config.port = 3000
6
+ config.server.port = 3000
7
+
8
+ # Specify the logger
9
+ config.logger = Rage::Logger.new("log/test.log")
7
10
  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.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -22,8 +22,9 @@ module Rage
22
22
  @config ||= Rage::Configuration.new
23
23
  end
24
24
 
25
- def self.configure
26
- yield(config)
25
+ def self.configure(&)
26
+ config.instance_eval(&)
27
+ config.__finalize
27
28
  end
28
29
 
29
30
  def self.env
@@ -38,6 +39,10 @@ module Rage
38
39
  @root ||= Pathname.new(".").expand_path
39
40
  end
40
41
 
42
+ def self.logger
43
+ @logger ||= config.logger
44
+ end
45
+
41
46
  module Router
42
47
  module Strategies
43
48
  end
data/rage.gemspec CHANGED
@@ -29,5 +29,5 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency "thor", "~> 1.0"
31
31
  spec.add_dependency "rack", "~> 2.0"
32
- spec.add_dependency "rage-iodine", "~> 2.0"
32
+ spec.add_dependency "rage-iodine", "~> 2.2"
33
33
  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.4.0
4
+ version: 0.5.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-10-31 00:00:00.000000000 Z
11
+ date: 2023-11-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '2.0'
47
+ version: '2.2'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '2.0'
54
+ version: '2.2'
55
55
  description:
56
56
  email:
57
57
  - rsamoi@icloud.com
@@ -79,6 +79,8 @@ files:
79
79
  - lib/rage/errors.rb
80
80
  - lib/rage/fiber.rb
81
81
  - lib/rage/fiber_scheduler.rb
82
+ - lib/rage/logger/logger.rb
83
+ - lib/rage/logger/text_formatter.rb
82
84
  - lib/rage/params_parser.rb
83
85
  - lib/rage/request.rb
84
86
  - lib/rage/router/README.md
@@ -89,7 +91,9 @@ files:
89
91
  - lib/rage/router/node.rb
90
92
  - lib/rage/router/strategies/host.rb
91
93
  - lib/rage/setup.rb
94
+ - lib/rage/sidekiq_session.rb
92
95
  - lib/rage/templates/Gemfile
96
+ - lib/rage/templates/Rakefile
93
97
  - lib/rage/templates/app-controllers-application_controller.rb
94
98
  - lib/rage/templates/config-application.rb
95
99
  - lib/rage/templates/config-environments-development.rb