rage-rb 0.4.0 → 0.5.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: 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