rage-rb 0.3.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.
@@ -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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::ParamsParser
4
+ def self.prepare(env, url_params)
5
+ has_body, query_string, content_type = env["IODINE_HAS_BODY"], env["QUERY_STRING"], env["CONTENT_TYPE"]
6
+
7
+ query_params = Iodine::Rack::Utils.parse_nested_query(query_string) if query_string != ""
8
+ unless has_body
9
+ if query_params
10
+ return query_params.merge!(url_params)
11
+ else
12
+ return url_params
13
+ end
14
+ end
15
+
16
+ request_params = if content_type.start_with?("application/json")
17
+ json_parse(env["rack.input"].read)
18
+ elsif content_type.start_with?("application/x-www-form-urlencoded")
19
+ Iodine::Rack::Utils.parse_urlencoded_nested_query(env["rack.input"].read)
20
+ else
21
+ Iodine::Rack::Utils.parse_multipart(env["rack.input"], content_type)
22
+ end
23
+
24
+ if request_params && !query_params
25
+ request_params.merge!(url_params)
26
+ elsif request_params && query_params
27
+ request_params.merge!(query_params, url_params)
28
+ else
29
+ url_params
30
+ end
31
+
32
+ rescue => e
33
+ raise Rage::Errors::BadRequest
34
+ end
35
+
36
+ if defined?(::FastJsonparser)
37
+ def self.json_parse(json)
38
+ FastJsonparser.parse(json, symbolize_keys: true)
39
+ end
40
+ else
41
+ def self.json_parse(json)
42
+ JSON.parse(json, symbolize_names: true)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Request
4
+ # @private
5
+ def initialize(env)
6
+ @env = env
7
+ end
8
+
9
+ # Get the request headers.
10
+ # @example
11
+ # request.headers["Content-Type"] # => "application/json"
12
+ # request.headers["Connection"] # => "keep-alive"
13
+ def headers
14
+ @headers ||= Headers.new(@env)
15
+ end
16
+
17
+ # @private
18
+ class Headers
19
+ HTTP = "HTTP_"
20
+
21
+ def initialize(env)
22
+ @env = env
23
+ end
24
+
25
+ def [](requested_header)
26
+ if requested_header.start_with?(HTTP)
27
+ @env[requested_header]
28
+ else
29
+ (requested_header = requested_header.tr("-", "_")).upcase!
30
+
31
+ if "CONTENT_TYPE" == requested_header || "CONTENT_LENGTH" == requested_header
32
+ @env[requested_header]
33
+ else
34
+ @env["#{HTTP}#{requested_header}"]
35
+ end
36
+ end
37
+ end
38
+
39
+ def inspect
40
+ headers = @env.select { |k| k == "CONTENT_TYPE" || k == "CONTENT_LENGTH" || k.start_with?(HTTP) }
41
+ "#<#{self.class.name} @headers=#{headers.inspect}"
42
+ end
43
+ end # class Headers
44
+ 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