rage-rb 0.7.0 → 1.1.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: 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