rage-rb 0.7.0 → 1.1.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: 1c3041038ae63ae245e261a7a2096195ab56291d236414e086646ae96a4a6d16
4
- data.tar.gz: a64817ded16716fe714310c7318d8e1d318454615da43d37055b333e76778fa2
3
+ metadata.gz: fd10469efc73f6134f72f79fe21871238b8860e958c085499c47b312adf91764
4
+ data.tar.gz: 34990a4885df4a4acd55fcbabf8d3a67e57e0609db406ee55d5f71cab0655ffb
5
5
  SHA512:
6
- metadata.gz: 350f5150012852a3ca2532c031ce8ef28338da6d985ef66dff59095dea6b3dbbc4498826996876722bc5b7991683fed82c2fd3854313f7ed59f9fff20aaf4315
7
- data.tar.gz: 84cd798056a43c8eb0e94809f72f6b0ac602076aa904831a3a049a8ed13352cad9321329233063343b73e65c0fbf1521564b0cee6f3696c89877a0af15191c94
6
+ metadata.gz: bc28afd194f122bf436c215cb6d88dfe3d923874f4e07c3c1db5dd3f33a4676354731fad3f6ffc29326423086b59dbb0d1860ecb5cb71cf4c7f0412a252af16f
7
+ data.tar.gz: 58cd0cea6daba63d67f9285d3b616980d2e69c778620c54b14c7c5cb1eccf093bfbc0644dec081c325d4cdc5f6c032b7f399ccb8ea5b052c1da401dcde08ed6e
data/.yardopts CHANGED
@@ -1 +1 @@
1
- --exclude lib/rage/templates --markup markdown --no-private -o doc
1
+ --exclude lib/rage/templates --exclude lib/rage/rspec --exclude lib/rage/rails --markup markdown --no-private -o doc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.1.0] - 2024-03-25
4
+
5
+ ### Changed
6
+
7
+ - Change the way controller names are logged (#72).
8
+ - Use formatters in console (#71).
9
+
10
+ ### Fixed
11
+
12
+ - Fix Fiber.await behavior in RSpec (#70).
13
+
14
+ ## [1.0.0] - 2024-03-13
15
+
16
+ ### Added
17
+
18
+ - RSpec integration (#60).
19
+ - Add DNS cache (#65).
20
+ - Allow to disable the `FiberScheduler#io_write` hook (#63).
21
+
22
+ ### Fixed
23
+
24
+ - Preload fiber ID (#62).
25
+ - Release ActiveRecord connections on yield (#66).
26
+ - Logger fixes (#64).
27
+ - Fix publish calls in cluster mode (#67).
28
+
3
29
  ## [0.7.0] - 2024-01-09
4
30
 
5
31
  - Add conditional GET using `stale?` by [@tonekk](https://github.com/tonekk) (#55).
data/README.md CHANGED
@@ -53,8 +53,12 @@ Check out in-depth API docs for more information:
53
53
  - [Fiber API](https://rage-rb.pages.dev/Fiber)
54
54
  - [Logger API](https://rage-rb.pages.dev/Rage/Logger)
55
55
  - [Configuration API](https://rage-rb.pages.dev/Rage/Configuration)
56
+ - [CORS middleware](https://rage-rb.pages.dev/Rage/Cors)
56
57
 
57
- 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.
58
+ Also, see the following integration guides:
59
+
60
+ - [Rails integration](https://github.com/rage-rb/rage/wiki/Rails-integration)
61
+ - [RSpec integration](https://github.com/rage-rb/rage/wiki/RSpec-integration)
58
62
 
59
63
  ### Example
60
64
 
@@ -150,8 +154,8 @@ Status | Changes
150
154
  :white_check_mark: | ~~Expose the `params` object.<br>Support header authentication with `authenticate_with_http_token`.<br>Router updates:<br>&emsp;• add the `resources` route helper;<br>&emsp;• add the `namespace` route helper;~~
151
155
  :white_check_mark: | ~~Add request logging.~~
152
156
  :white_check_mark: | ~~Automatic code reloading in development with Zeitwerk.~~
157
+ :white_check_mark: | ~~Support conditional get with `etag` and `last_modified`.~~
153
158
  ⏳ | Expose the `send_data` and `send_file` methods.
154
- ⏳ | Support conditional get with `etag` and `last_modified`.
155
159
  ⏳ | Expose the `cookies` and `session` objects.
156
160
  ⏳ | Implement Iodine-based equivalent of Action Cable.
157
161
 
data/lib/rage/all.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "router/constrainer"
18
18
  require_relative "router/dsl"
19
19
  require_relative "router/handler_storage"
20
20
  require_relative "router/node"
21
+ require_relative "router/util"
21
22
 
22
23
  require_relative "controller/api"
23
24
 
@@ -19,7 +19,7 @@
19
19
  #
20
20
  # • _config.middleware.use_
21
21
  #
22
- # > Adds a middleware to the top of the middleware stack. **This is the preferred way of adding a middleware.**
22
+ # > Adds a middleware to the top of the middleware stack. **This is the recommended way of adding a middleware.**
23
23
  # > ```
24
24
  # config.middleware.use Rack::Cors do
25
25
  # allow do
@@ -166,7 +166,7 @@ class Rage::Configuration
166
166
 
167
167
  # @private
168
168
  class Internal
169
- attr_accessor :rails_mode, :rails_console
169
+ attr_accessor :rails_mode
170
170
 
171
171
  def inspect
172
172
  "#<#{self.class.name}>"
data/lib/rage/fiber.rb CHANGED
@@ -1,6 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # Rage provides a simple and efficient API to wait on several instances of IO at the same time - {Fiber.await}.
5
+ #
6
+ # Let's say we have the following controller:
7
+ # ```ruby
8
+ # class UsersController < RageController::API
9
+ # def show
10
+ # user = Net::HTTP.get(URI("http://users.service/users/#{params[:id]}"))
11
+ # bookings = Net::HTTP.get(URI("http://bookings.service/bookings?user_id=#{params[:id]}"))
12
+ # render json: { user: user, bookings: bookings }
13
+ # end
14
+ # end
15
+ # ```
16
+ # This code will fire two consecutive HTTP requests. If each request takes 1 second to execute, the total execution time will be 2 seconds.<br>
17
+ # With {Fiber.await}, we can significantly decrease the overall execution time by changing the code to fire the requests concurrently.
18
+ #
19
+ # To do this, we will need to:
20
+ #
21
+ # 1. Wrap every request in a separate fiber using {Fiber.schedule};
22
+ # 2. Pass newly created fibers into {Fiber.await};
23
+ #
24
+ # ```ruby
25
+ # class UsersController < RageController::API
26
+ # def show
27
+ # user, bookings = Fiber.await([
28
+ # Fiber.schedule { Net::HTTP.get(URI("http://users.service/users/#{params[:id]}")) },
29
+ # Fiber.schedule { Net::HTTP.get(URI("http://bookings.service/bookings?user_id=#{params[:id]}")) }
30
+ # ])
31
+ #
32
+ # render json: { user: user, bookings: bookings }
33
+ # end
34
+ # end
35
+ # ```
36
+ # With this change, if each request takes 1 second to execute, the total execution time will still be 1 second.
37
+ #
38
+ # ## Creating fibers
39
+ # Many developers see fibers as "lightweight threads" that should be used in conjunction with fiber pools, the same way we use thread pools for threads.<br>
40
+ # Instead, it makes sense to think of fibers as regular Ruby objects. We don't use a pool of arrays when we need to create an array - we create a new object and let Ruby and the GC do their job.<br>
41
+ # Same applies to fibers. Feel free to create as many fibers as you need on demand.
3
42
  class Fiber
43
+ # @private
4
44
  AWAIT_ERROR_MESSAGE = "err"
5
45
 
6
46
  # @private
@@ -23,14 +63,14 @@ class Fiber
23
63
  @__err
24
64
  end
25
65
 
26
- # @private
27
- def __get_id
28
- @__rage_id ||= object_id.to_s
66
+ # @private
67
+ def __set_id
68
+ @__rage_id = object_id.to_s
29
69
  end
30
70
 
31
- # @private
32
- def __yielded?
33
- !@__rage_id.nil?
71
+ # @private
72
+ def __get_id
73
+ @__rage_id
34
74
  end
35
75
 
36
76
  # @private
@@ -49,8 +89,15 @@ class Fiber
49
89
  Fiber.yield
50
90
  end
51
91
 
92
+ # @private
93
+ # under normal circumstances, the method is a copy of `yield`, but it can be overriden to perform
94
+ # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/rails.rb"
95
+ class << self
96
+ alias_method :defer, :yield
97
+ end
98
+
52
99
  # Wait on several fibers at the same time. Calling this method will automatically pause the current fiber, allowing the
53
- # server to process other requests. Once all fibers have completed, the current fiber will be automatically resumed.
100
+ # server to process other requests. Once all fibers have completed, the current fiber will be automatically resumed.
54
101
  #
55
102
  # @param fibers [Fiber, Array<Fiber>] one or several fibers to wait on. The fibers must be created using the `Fiber.schedule` call.
56
103
  # @example
@@ -101,4 +148,16 @@ class Fiber
101
148
  fibers.map!(&:__get_result)
102
149
  end
103
150
  end
151
+
152
+ # @!method self.schedule(&block)
153
+ # Create a non-blocking fiber. Should mostly be used in conjunction with `Fiber.await`.
154
+ # @example
155
+ # Fiber.await([
156
+ # Fiber.schedule { request_1 },
157
+ # Fiber.schedule { request_2 }
158
+ # ])
159
+ # @example
160
+ # fiber_1 = Fiber.schedule { request_1 }
161
+ # fiber_2 = Fiber.schedule { request_2 }
162
+ # Fiber.await([fiber_1, fiber_2])
104
163
  end
@@ -7,13 +7,14 @@ class Rage::FiberScheduler
7
7
 
8
8
  def initialize
9
9
  @root_fiber = Fiber.current
10
+ @dns_cache = {}
10
11
  end
11
12
 
12
13
  def io_wait(io, events, timeout = nil)
13
14
  f = Fiber.current
14
15
  ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { |err| f.resume(err) }
15
16
 
16
- err = Fiber.yield
17
+ err = Fiber.defer
17
18
  if err == Errno::ETIMEDOUT::Errno
18
19
  0
19
20
  else
@@ -49,13 +50,15 @@ class Rage::FiberScheduler
49
50
  end
50
51
  end
51
52
 
52
- def io_write(io, buffer, length, offset = 0)
53
- bytes_to_write = length
54
- bytes_to_write = buffer.size if length == 0
53
+ unless ENV["RAGE_DISABLE_IO_WRITE"]
54
+ def io_write(io, buffer, length, offset = 0)
55
+ bytes_to_write = length
56
+ bytes_to_write = buffer.size if length == 0
55
57
 
56
- ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
58
+ ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
57
59
 
58
- bytes_to_write - offset
60
+ bytes_to_write - offset
61
+ end
59
62
  end
60
63
 
61
64
  def kernel_sleep(duration = nil)
@@ -77,7 +80,13 @@ class Rage::FiberScheduler
77
80
  # end
78
81
 
79
82
  def address_resolve(hostname)
80
- Resolv.getaddresses(hostname)
83
+ @dns_cache[hostname] ||= begin
84
+ ::Iodine.run_after(60_000) do
85
+ @dns_cache[hostname] = nil
86
+ end
87
+
88
+ Resolv.getaddresses(hostname)
89
+ end
81
90
  end
82
91
 
83
92
  def block(_blocker, timeout = nil)
@@ -100,7 +109,7 @@ class Rage::FiberScheduler
100
109
  end
101
110
 
102
111
  def unblock(_blocker, fiber)
103
- ::Iodine.publish(fiber.__block_channel, "")
112
+ ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS)
104
113
  end
105
114
 
106
115
  def fiber(&block)
@@ -109,6 +118,7 @@ class Rage::FiberScheduler
109
118
  fiber = if parent == @root_fiber
110
119
  # the fiber to wrap a request in
111
120
  Fiber.new(blocking: false) do
121
+ Fiber.current.__set_id
112
122
  Fiber.current.__set_result(block.call)
113
123
  end
114
124
  else
@@ -119,10 +129,10 @@ class Rage::FiberScheduler
119
129
  Thread.current[:rage_logger] = logger
120
130
  Fiber.current.__set_result(block.call)
121
131
  # send a message for `Fiber.await` to work
122
- Iodine.publish("await:#{parent.object_id}", "") if parent.alive?
132
+ Iodine.publish("await:#{parent.object_id}", "", Iodine::PubSub::PROCESS) if parent.alive?
123
133
  rescue Exception => e
124
134
  Fiber.current.__set_err(e)
125
- Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE) if parent.alive?
135
+ Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
126
136
  end
127
137
  end
128
138
 
@@ -7,7 +7,7 @@ class Rage::JSONFormatter
7
7
  end
8
8
 
9
9
  def call(severity, timestamp, _, message)
10
- logger = Thread.current[:rage_logger]
10
+ logger = Thread.current[:rage_logger] || { tags: [], context: {} }
11
11
  tags, context = logger[:tags], logger[:context]
12
12
 
13
13
  if !context.empty?
@@ -17,8 +17,8 @@ class Rage::JSONFormatter
17
17
 
18
18
  if final = logger[:final]
19
19
  params, env = final[:params], final[:env]
20
- if params
21
- return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",\"controller\":\"#{params[:controller]}\",\"action\":\"#{params[:action]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n"
20
+ if params && params[:controller]
21
+ return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",\"controller\":\"#{Rage::Router::Util.path_to_name(params[:controller])}\",\"action\":\"#{params[:action]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n"
22
22
  else
23
23
  # no controller/action keys are written if there are no params
24
24
  return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n"
@@ -29,6 +29,8 @@ class Rage::JSONFormatter
29
29
  tags_msg = "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
30
30
  elsif tags.length == 2
31
31
  tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
32
+ elsif tags.length == 0
33
+ tags_msg = "{\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\""
32
34
  else
33
35
  tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\""
34
36
  i = 2
@@ -33,6 +33,23 @@ require "logger"
33
33
  # Rage.logger.info("Initializing")
34
34
  # Rage.logger.debug { "This is a " + potentially + " expensive operation" }
35
35
  # ```
36
+ #
37
+ # ## Using the logger
38
+ # The recommended approach to logging with Rage is to make sure your code always logs the same message no matter what the input is.
39
+ # You can achieve this by using the {with_context} and {tagged} methods. So, a code like this:
40
+ # ```ruby
41
+ # def process_purchase(user_id:, product_id:)
42
+ # Rage.logger.info "processing purchase with user_id = #{user_id}; product_id = #{product_id}"
43
+ # end
44
+ # ```
45
+ # turns into this:
46
+ # ```ruby
47
+ # def process_purchase(user_id:, product_id:)
48
+ # Rage.logger.with_context(user_id: user_id, product_id: product_id) do
49
+ # Rage.logger.info "processing purchase"
50
+ # end
51
+ # end
52
+ # ```
36
53
  class Rage::Logger
37
54
  METHODS_MAP = {
38
55
  "debug" => Logger::DEBUG,
@@ -83,7 +100,7 @@ class Rage::Logger
83
100
  # Rage.logger.info "cache miss"
84
101
  # end
85
102
  def with_context(context)
86
- old_context = Thread.current[:rage_logger][:context]
103
+ old_context = (Thread.current[:rage_logger] ||= { tags: [], context: {} })[:context]
87
104
 
88
105
  if old_context.empty? # there's nothing in the context yet
89
106
  Thread.current[:rage_logger][:context] = context
@@ -92,8 +109,6 @@ class Rage::Logger
92
109
  end
93
110
 
94
111
  yield(self)
95
- true
96
-
97
112
  ensure
98
113
  Thread.current[:rage_logger][:context] = old_context
99
114
  end
@@ -106,17 +121,20 @@ class Rage::Logger
106
121
  # Rage.logger.info "success"
107
122
  # end
108
123
  def tagged(tag)
109
- Thread.current[:rage_logger][:tags] << tag
110
-
124
+ (Thread.current[:rage_logger] ||= { tags: [], context: {} })[:tags] << tag
111
125
  yield(self)
112
- true
113
-
114
126
  ensure
115
127
  Thread.current[:rage_logger][:tags].pop
116
128
  end
117
129
 
118
130
  alias_method :with_tag, :tagged
119
131
 
132
+ def debug? = @level <= Logger::DEBUG
133
+ def error? = @level <= Logger::ERROR
134
+ def fatal? = @level <= Logger::FATAL
135
+ def info? = @level <= Logger::INFO
136
+ def warn? = @level <= Logger::WARN
137
+
120
138
  private
121
139
 
122
140
  def define_log_methods
@@ -128,13 +146,6 @@ class Rage::Logger
128
146
  false
129
147
  end
130
148
  RUBY
131
- elsif (Rage.config.internal.rails_mode ? Rage.config.internal.rails_console : defined?(IRB))
132
- # the call was made from the console - don't use the formatter
133
- <<-RUBY
134
- def #{level_name}(msg = nil)
135
- @logdev.write((msg || yield) + "\n")
136
- end
137
- RUBY
138
149
  elsif @formatter.class.name.start_with?("Rage::")
139
150
  # the call was made from within the application and a built-in formatter is used;
140
151
  # in such case we use the `gen_timestamp` method which is much faster than `Time.now.strftime`;
@@ -7,7 +7,7 @@ class Rage::TextFormatter
7
7
  end
8
8
 
9
9
  def call(severity, timestamp, _, message)
10
- logger = Thread.current[:rage_logger]
10
+ logger = Thread.current[:rage_logger] || { tags: [], context: {} }
11
11
  tags, context = logger[:tags], logger[:context]
12
12
 
13
13
  if !context.empty?
@@ -17,8 +17,8 @@ class Rage::TextFormatter
17
17
 
18
18
  if final = logger[:final]
19
19
  params, env = final[:params], final[:env]
20
- if params
21
- return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} controller=#{params[:controller]} action=#{params[:action]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n"
20
+ if params && params[:controller]
21
+ return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} controller=#{Rage::Router::Util.path_to_name(params[:controller])} action=#{params[:action]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n"
22
22
  else
23
23
  # no controller/action keys are written if there are no params
24
24
  return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n"
@@ -29,6 +29,8 @@ class Rage::TextFormatter
29
29
  tags_msg = "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
30
30
  elsif tags.length == 2
31
31
  tags_msg = "[#{tags[0]}][#{tags[1]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
32
+ elsif tags.length == 0
33
+ tags_msg = "timestamp=#{timestamp} pid=#{@pid} level=#{severity}"
32
34
  else
33
35
  tags_msg = "[#{tags[0]}][#{tags[1]}]"
34
36
  i = 2
@@ -17,7 +17,7 @@ class Rage::FiberWrapper
17
17
  @app.call(env)
18
18
  ensure
19
19
  # notify Iodine the request can now be resumed
20
- Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS) if Fiber.current.__yielded?
20
+ Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS)
21
21
  end
22
22
 
23
23
  # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request
data/lib/rage/rails.rb CHANGED
@@ -11,12 +11,6 @@ Iodine.patch_rack
11
11
  # configure the framework
12
12
  Rage.config.internal.rails_mode = true
13
13
 
14
- # make sure log formatter is not used in console
15
- Rails.application.console do
16
- Rage.config.internal.rails_console = true
17
- Rage.logger.level = Rage.logger.level if Rage.logger # trigger redefining log methods
18
- end
19
-
20
14
  # patch ActiveRecord's connection pool
21
15
  if defined?(ActiveRecord)
22
16
  Rails.configuration.after_initialize do
@@ -30,6 +24,21 @@ if defined?(ActiveRecord)
30
24
  end
31
25
  end
32
26
 
27
+ # release ActiveRecord connections on yield
28
+ if defined?(ActiveRecord)
29
+ class Fiber
30
+ def self.defer
31
+ res = Fiber.yield
32
+
33
+ if ActiveRecord::Base.connection_pool.active_connection?
34
+ ActiveRecord::Base.connection_handler.clear_active_connections!
35
+ end
36
+
37
+ res
38
+ end
39
+ end
40
+ end
41
+
33
42
  # plug into Rails' Zeitwerk instance to reload the code
34
43
  Rails.autoloaders.main.on_setup do
35
44
  if Iodine.running?
@@ -67,7 +67,7 @@ class Rage::Router::Backend
67
67
  if handler.is_a?(String)
68
68
  raise "Invalid route handler format, expected to match the 'controller#action' pattern" unless handler =~ STRING_HANDLER_REGEXP
69
69
 
70
- controller, action = to_controller_class($1), $2
70
+ controller, action = Rage::Router::Util.path_to_class($1), $2
71
71
  run_action_method_name = controller.__register_action(action.to_sym)
72
72
 
73
73
  meta[:controller] = $1
@@ -273,22 +273,4 @@ class Rage::Router::Backend
273
273
  end
274
274
  end
275
275
  end
276
-
277
- def to_controller_class(str)
278
- str.capitalize!
279
- str.gsub!(/([\/_])([a-zA-Z0-9]+)/) do
280
- if $1 == "/"
281
- "::#{$2.capitalize}"
282
- else
283
- $2.capitalize
284
- end
285
- end
286
-
287
- klass = "#{str}Controller"
288
- if Object.const_defined?(klass)
289
- Object.const_get(klass)
290
- else
291
- raise Rage::Errors::RouterError, "Routing error: could not find the #{klass} class"
292
- end
293
- end
294
276
  end
@@ -48,10 +48,11 @@ class Rage::Router::HandlerStorage
48
48
  private
49
49
 
50
50
  def compile_create_params_object(param_keys, defaults, meta)
51
- lines = [
52
- ":controller => '#{meta[:controller]}'.freeze",
53
- ":action => '#{meta[:action]}'.freeze"
54
- ]
51
+ lines = if meta[:controller]
52
+ [":controller => '#{meta[:controller]}'.freeze", ":action => '#{meta[:action]}'.freeze"]
53
+ else
54
+ []
55
+ end
55
56
 
56
57
  param_keys.each_with_index do |key, i|
57
58
  lines << ":#{key} => param_values[#{i}]"
@@ -0,0 +1,33 @@
1
+ class Rage::Router::Util
2
+ class << self
3
+ # converts controller name in a path form into a class
4
+ # `api/v1/users` => `Api::V1::UsersController`
5
+ def path_to_class(str)
6
+ str = str.capitalize
7
+ str.gsub!(/([\/_])([a-zA-Z0-9]+)/) do
8
+ if $1 == "/"
9
+ "::#{$2.capitalize}"
10
+ else
11
+ $2.capitalize
12
+ end
13
+ end
14
+
15
+ klass = "#{str}Controller"
16
+ if Object.const_defined?(klass)
17
+ Object.const_get(klass)
18
+ else
19
+ raise Rage::Errors::RouterError, "Routing error: could not find the #{klass} class"
20
+ end
21
+ end
22
+
23
+ @@names_map = {}
24
+
25
+ # converts controller name in a path form into a string representation of a class
26
+ # `api/v1/users` => `"Api::V1::UsersController"`
27
+ def path_to_name(str)
28
+ @@names_map[str] || begin
29
+ @@names_map[str] = path_to_class(str).name
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/rage/rspec.rb ADDED
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/test"
4
+ require "json"
5
+
6
+ # set up environment
7
+ ENV["RAGE_ENV"] ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test"
8
+
9
+ # load the app
10
+ require "bundler/setup"
11
+ require "rage"
12
+ require_relative "#{Rage.root}/config/application"
13
+
14
+ # verify the environment
15
+ abort("The test suite is running in #{Rage.env} mode instead of 'test'!") unless Rage.env.test?
16
+
17
+ # mock fiber methods as RSpec tests don't run concurrently
18
+ class Fiber
19
+ def self.schedule(&block)
20
+ fiber = Fiber.new(blocking: true) do
21
+ Fiber.current.__set_id
22
+ Fiber.current.__set_result(block.call)
23
+ end
24
+ fiber.resume
25
+
26
+ fiber
27
+ end
28
+
29
+ def self.await(fibers)
30
+ Array(fibers).map(&:__get_result)
31
+ end
32
+ end
33
+
34
+ # define request helpers
35
+ module RageRequestHelpers
36
+ include Rack::Test::Methods
37
+
38
+ alias_method :response, :last_response
39
+
40
+ APP = Rack::Builder.parse_file("#{Rage.root}/config.ru").yield_self do |app|
41
+ app.is_a?(Array) ? app[0] : app
42
+ end
43
+
44
+ def app
45
+ APP
46
+ end
47
+
48
+ %w(get options head).each do |method_name|
49
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
50
+ def #{method_name}(path, params: {}, headers: {})
51
+ request("#{method_name.upcase}", path, params: params, headers: headers)
52
+ end
53
+ RUBY
54
+ end
55
+
56
+ %w(post put patch delete).each do |method_name|
57
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
58
+ def #{method_name}(path, params: {}, headers: {}, as: nil)
59
+ if as == :json
60
+ params = params.to_json
61
+ headers["content-type"] = "application/json"
62
+ end
63
+
64
+ request("#{method_name.upcase}", path, params: params, headers: headers.merge("IODINE_HAS_BODY" => !params.empty?))
65
+ end
66
+ RUBY
67
+ end
68
+
69
+ def request(method, path, params: {}, headers: {})
70
+ if headers.any?
71
+ headers = headers.transform_keys do |k|
72
+ if k.downcase == "content-type"
73
+ "CONTENT_TYPE"
74
+ elsif k.downcase == "content-length"
75
+ "CONTENT_LENGTH"
76
+ elsif k.upcase == k
77
+ k
78
+ else
79
+ "HTTP_#{k.tr("-", "_").upcase! || k}"
80
+ end
81
+ end
82
+ end
83
+
84
+ custom_request(method, path, params, headers)
85
+ end
86
+
87
+ def host!(host)
88
+ @__host = host
89
+ end
90
+
91
+ def default_host
92
+ @__host || "example.org"
93
+ end
94
+ end
95
+
96
+ # include request helpers
97
+ RSpec.configure do |config|
98
+ config.include(RageRequestHelpers, type: :request)
99
+ end
100
+
101
+ # patch MockResponse class
102
+ class Rack::MockResponse
103
+ def parsed_body
104
+ if headers["content-type"].start_with?("application/json")
105
+ JSON.parse(body)
106
+ else
107
+ body
108
+ end
109
+ end
110
+
111
+ def code
112
+ status.to_s
113
+ end
114
+
115
+ alias_method :response_code, :status
116
+ end
117
+
118
+ # define http status matcher
119
+ RSpec::Matchers.matcher :have_http_status do |expected|
120
+ codes = Rack::Utils::SYMBOL_TO_STATUS_CODE
121
+
122
+ failure_message do |response|
123
+ actual = response.status
124
+
125
+ if expected.is_a?(Integer)
126
+ "expected the response to have status code #{expected} but it was #{actual}"
127
+ elsif expected == :success
128
+ "expected the response to have a success status code (2xx) but it was #{actual}"
129
+ elsif expected == :error
130
+ "expected the response to have an error status code (5xx) but it was #{actual}"
131
+ elsif expected == :missing
132
+ "expected the response to have a missing status code (404) but it was #{actual}"
133
+ else
134
+ "expected the response to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})"
135
+ end
136
+ end
137
+
138
+ failure_message_when_negated do |response|
139
+ actual = response.status
140
+
141
+ if expected.is_a?(Integer)
142
+ "expected the response not to have status code #{expected} but it was #{actual}"
143
+ elsif expected == :success
144
+ "expected the response not to have a success status code (2xx) but it was #{actual}"
145
+ elsif expected == :error
146
+ "expected the response not to have an error status code (5xx) but it was #{actual}"
147
+ elsif expected == :missing
148
+ "expected the response not to have a missing status code (404) but it was #{actual}"
149
+ else
150
+ "expected the response not to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})"
151
+ end
152
+ end
153
+
154
+ match do |response|
155
+ actual = response.status
156
+
157
+ case expected
158
+ when :success
159
+ actual >= 200 && actual < 300
160
+ when :error
161
+ actual >= 500
162
+ when :missing
163
+ actual == 404
164
+ when Symbol
165
+ actual == codes.fetch(expected)
166
+ else
167
+ actual == expected
168
+ end
169
+ end
170
+ end
171
+
172
+ if defined? RSpec::Rails::Matchers
173
+ module RSpec::Rails::Matchers
174
+ def have_http_status(_)
175
+ super
176
+ end
177
+ end
178
+ end
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "0.7.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/rage.gemspec CHANGED
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "rack", "~> 2.0"
32
32
  spec.add_dependency "rage-iodine", "~> 3.0"
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
+ spec.add_dependency "rack-test", "~> 2.1"
34
35
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 1.1.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: 2024-01-09 00:00:00.000000000 Z
11
+ date: 2024-03-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack-test
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.1'
69
83
  description:
70
84
  email:
71
85
  - rsamoi@icloud.com
@@ -112,6 +126,8 @@ files:
112
126
  - lib/rage/router/handler_storage.rb
113
127
  - lib/rage/router/node.rb
114
128
  - lib/rage/router/strategies/host.rb
129
+ - lib/rage/router/util.rb
130
+ - lib/rage/rspec.rb
115
131
  - lib/rage/setup.rb
116
132
  - lib/rage/sidekiq_session.rb
117
133
  - lib/rage/templates/Gemfile