rage-rb 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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