rage-rb 0.5.1 → 0.6.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: 15d0d4682a307384b3e291b5b9d2f4f0323fc5ca74966b06a856a1f1367da01d
4
- data.tar.gz: 4a5d0f7ef64f5b70615e0b1f43a170ba7072b0b44d908858cf43d5dc4cc3fb2d
3
+ metadata.gz: 0eed317e69386a2c2cc765df7be687be754934801390e96013902e78670171eb
4
+ data.tar.gz: 44c899c5843d112ca222df4b863852df9e3728a63954ee3b5ab4ffd2cffd4e2e
5
5
  SHA512:
6
- metadata.gz: d7c3a2cf837d4038618e925508af8b536040927cc9506beaed3f312f1176903dc5a0a1141c47321eb5b7f2ba20a21e4e9e635d54e098ed7b4e04ec93fba08da0
7
- data.tar.gz: e48c1bf19c83f09a21f5b0c8c731db109a24fe0c37de870454b2b155c5d85be98c890d555bec1b78a00c19f91e7a25a3cbc99df6937561d30a88e96027d10e4d
6
+ metadata.gz: 4e158fd8202e9c6e4f94a7b243383c34f2179306a10724978083b6599197197221915857540ae2beb5ea98a5f602c6507bec89d192a57706b3aae2203c75a8a3
7
+ data.tar.gz: e9723bb6a0ef7b1a67db08799e3429ae4c2e9f88894afb9e614645673df7cb4e019107f0ca1339eb521fa7958fd259ca032385f98fdbddacd59415d960f0c347
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2023-12-22
4
+
5
+ ### Added
6
+
7
+ - Implement after actions (#53).
8
+ - Zeitwerk autoloading and reloading by [@alex-rogachev](https://github.com/alex-rogachev) (#54).
9
+ - Support the `environment`, `binding`, `timeout`, and `max_clients` options when using `rage s` (#52).
10
+ - Add CORS middleware (#49).
11
+
12
+ ### Fixed
13
+
14
+ - Prevent `block` and `sleep` channels from conflicting (#51).
15
+
16
+ ## [0.5.2] - 2023-12-11
17
+
18
+ ### Added
19
+
20
+ - Add env class (#43).
21
+
22
+ ### Changed
23
+
24
+ - Schedule request Fibers in a separate middleware (#48).
25
+
3
26
  ## [0.5.1] - 2023-12-01
4
27
 
5
28
  ### Fixed
data/CODE_OF_CONDUCT.md CHANGED
@@ -39,7 +39,7 @@ This Code of Conduct applies within all community spaces, and also applies when
39
39
 
40
40
  ## Enforcement
41
41
 
42
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at roman.samoilov@wework.com. All complaints will be reviewed and investigated promptly and fairly.
42
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at developers@rage-rb.dev. All complaints will be reviewed and investigated promptly and fairly.
43
43
 
44
44
  All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45
45
 
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/rage-rb.svg)](https://badge.fury.io/rb/rage-rb)
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
+ ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.1%2B-%23f40000)
8
+
7
9
 
8
10
  Inspired by [Deno](https://deno.com) and built on top of [Iodine](https://github.com/rage-rb/iodine), this is a Ruby web framework that is based on the following design principles:
9
11
 
@@ -11,7 +13,7 @@ Inspired by [Deno](https://deno.com) and built on top of [Iodine](https://github
11
13
 
12
14
  * **High performance** - some think performance is not a major metric for a framework, but it's not true. Poor performance is a risk, and in today's world, companies refuse to use risky technologies.
13
15
 
14
- * **API-only** - the only technology we should be using to create web UI is JavaScript. Using native technologies is always the most flexible, scalable, and simple solution in the long run. Check out [Vite](https://vitejs.dev) if you don't know where to start.
16
+ * **API-only** - separation of concerns is one of the most fundamental principles in software development. Backend and frontend are very different layers with different goals and paths to those goals. Separating BE code from FE code results in a much more sustainable architecture compared with classic Rails monoliths.
15
17
 
16
18
  * **Acceptance of modern Ruby** - the framework includes a fiber scheduler, which means your code never blocks while waiting on IO.
17
19
 
@@ -50,6 +52,7 @@ Check out in-depth API docs for more information:
50
52
  - [Routing API](https://rage-rb.pages.dev/Rage/Router/DSL/Handler)
51
53
  - [Fiber API](https://rage-rb.pages.dev/Fiber)
52
54
  - [Logger API](https://rage-rb.pages.dev/Rage/Logger)
55
+ - [Configuration API](https://rage-rb.pages.dev/Rage/Configuration)
53
56
 
54
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.
55
58
 
data/lib/rage/all.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "request"
9
9
  require_relative "uploaded_file"
10
10
  require_relative "errors"
11
11
  require_relative "params_parser"
12
+ require_relative "code_loader"
12
13
 
13
14
  require_relative "router/strategies/host"
14
15
  require_relative "router/backend"
@@ -22,6 +23,10 @@ require_relative "controller/api"
22
23
  require_relative "logger/text_formatter"
23
24
  require_relative "logger/logger"
24
25
 
26
+ require_relative "middleware/fiber_wrapper"
27
+ require_relative "middleware/cors"
28
+ require_relative "middleware/reloader"
29
+
25
30
  if defined?(Sidekiq)
26
31
  require_relative "sidekiq_session"
27
32
  end
@@ -2,41 +2,28 @@
2
2
 
3
3
  class Rage::Application
4
4
  def initialize(router)
5
- Iodine.on_state(:on_start) do
6
- Fiber.set_scheduler(Rage::FiberScheduler.new)
7
- end
8
5
  @router = router
9
6
  end
10
7
 
11
8
  def call(env)
12
- fiber = Fiber.schedule do
13
- init_logger
14
-
15
- handler = @router.lookup(env)
9
+ init_logger
16
10
 
17
- response = if handler
18
- params = Rage::ParamsParser.prepare(env, handler[:params])
19
- handler[:handler].call(env, params)
20
- else
21
- [404, {}, ["Not Found"]]
22
- end
11
+ handler = @router.lookup(env)
23
12
 
24
- rescue Exception => e
25
- exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
26
- Rage.logger.error(exception_str)
27
- response = [500, {}, [exception_str]]
28
-
29
- ensure
30
- finalize_logger(env, response, params)
31
- Iodine.publish(env["IODINE_REQUEST_ID"], "") # notify Iodine the request can now be served
32
- end
33
-
34
- # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request;
35
- if fiber.alive?
36
- [:__http_defer__, fiber]
13
+ response = if handler
14
+ params = Rage::ParamsParser.prepare(env, handler[:params])
15
+ handler[:handler].call(env, params)
37
16
  else
38
- fiber.__get_result
17
+ [404, {}, ["Not Found"]]
39
18
  end
19
+
20
+ rescue Exception => e
21
+ exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
22
+ Rage.logger.error(exception_str)
23
+ response = [500, {}, [exception_str]]
24
+
25
+ ensure
26
+ finalize_logger(env, response, params)
40
27
  end
41
28
 
42
29
  private
data/lib/rage/cli.rb CHANGED
@@ -16,11 +16,23 @@ module Rage
16
16
 
17
17
  desc "s", "Start the app server."
18
18
  option :port, aliases: "-p", desc: "Runs Rage on the specified port - defaults to 3000."
19
+ option :environment, aliases: "-e", desc: "Specifies the environment to run this server under (test/development/production)."
20
+ option :binding, aliases: "-b", desc: "Binds Rails to the specified IP - defaults to 'localhost' in development and '0.0.0.0' in other environments."
21
+ option :help, aliases: "-h", desc: "Show this message."
19
22
  def server
23
+ return help("server") if options.help?
24
+
25
+ set_env(options)
26
+
20
27
  app = ::Rack::Builder.parse_file("config.ru")
21
28
  app = app[0] if app.is_a?(Array)
22
29
 
23
- ::Iodine.listen service: :http, handler: app, port: options[:port] || Rage.config.server.port
30
+ port = options[:port] || Rage.config.server.port
31
+ address = options[:binding] || (Rage.env.production? ? "0.0.0.0" : "localhost")
32
+ timeout = Rage.config.server.timeout
33
+ max_clients = Rage.config.server.max_clients
34
+
35
+ ::Iodine.listen service: :http, handler: app, port: port, address: address, timeout: timeout, max_clients: max_clients
24
36
  ::Iodine.threads = Rage.config.server.threads_count
25
37
  ::Iodine.workers = Rage.config.server.workers_count
26
38
 
@@ -29,12 +41,15 @@ module Rage
29
41
 
30
42
  desc 'routes', 'List all routes.'
31
43
  option :grep, aliases: "-g", desc: "Filter routes by pattern"
44
+ option :help, aliases: "-h", desc: "Show this message."
32
45
  def routes
46
+ return help("routes") if options.help?
33
47
  # the result would be something like this:
34
48
  # Verb Path Controller#Action
35
49
  # GET / application#index
36
50
 
37
51
  # load config/application.rb
52
+ set_env(options)
38
53
  environment
39
54
 
40
55
  routes = Rage.__router.routes
@@ -83,7 +98,12 @@ module Rage
83
98
  end
84
99
 
85
100
  desc "c", "Start the app console."
101
+ option :help, aliases: "-h", desc: "Show this message."
86
102
  def console
103
+ return help("console") if options.help?
104
+
105
+ set_env(options)
106
+
87
107
  require "irb"
88
108
  environment
89
109
  ARGV.clear
@@ -95,6 +115,10 @@ module Rage
95
115
  def environment
96
116
  require File.expand_path("config/application.rb", Dir.pwd)
97
117
  end
118
+
119
+ def set_env(options)
120
+ ENV["RAGE_ENV"] = options[:environment] || ENV["RAGE_ENV"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
121
+ end
98
122
  end
99
123
 
100
124
  class NewAppGenerator < Thor::Group
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ class Rage::CodeLoader
6
+ def initialize
7
+ @loader = Zeitwerk::Loader.new
8
+ @reloading = false
9
+ end
10
+
11
+ def setup
12
+ autoload_path = "#{Rage.root}/app"
13
+ enable_reloading = Rage.env.development?
14
+ enable_eager_loading = !Rage.env.development? && !Rage.env.test?
15
+
16
+ @loader.push_dir(autoload_path)
17
+ # The first level of directories in app directory won't be treated as modules
18
+ # e.g. app/controllers/pages_controller.rb will be linked to PagesController class
19
+ # instead of Controllers::PagesController
20
+ @loader.collapse("#{Rage.root}/app/*")
21
+ @loader.enable_reloading if enable_reloading
22
+ @loader.setup
23
+ @loader.eager_load if enable_eager_loading
24
+ end
25
+
26
+ def reload
27
+ @reloading = true
28
+ @loader.reload
29
+ Rage.__router.reset_routes
30
+ load("#{Rage.root}/config/routes.rb")
31
+ end
32
+
33
+ def reloading?
34
+ @reloading
35
+ end
36
+ end
@@ -1,15 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # # General Configuration
5
+ #
6
+ # • _config.logger_
7
+ #
8
+ # > The logger that will be used for `Rage.logger` and any related `Rage` logging. Custom loggers should implement Ruby's {https://ruby-doc.org/3.2.2/stdlibs/logger/Logger.html#class-Logger-label-Entries Logger} interface.
9
+ #
10
+ # • _config.log_formatter_
11
+ #
12
+ # > The formatter of the Rage logger. Built in options include `Rage::TextFormatter` and `Rage::JSONFormatter`. Defaults to an instance of `Rage::TextFormatter`.
13
+ #
14
+ # • _config.log_level_
15
+ #
16
+ # > Defines the verbosity of the Rage logger. This option defaults to `:debug` for all environments except production, where it defaults to `:info`. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`.
17
+ #
18
+ # # Middleware Configuration
19
+ #
20
+ # • _config.middleware.use_
21
+ #
22
+ # > Adds a middleware to the top of the middleware stack. **This is the preferred way of adding a middleware.**
23
+ # > ```
24
+ # config.middleware.use Rack::Cors do
25
+ # allow do
26
+ # origins "*"
27
+ # resource "*", headers: :any
28
+ # end
29
+ # end
30
+ # > ```
31
+ #
32
+ # • _config.middleware.insert_before_
33
+ #
34
+ # > Adds middleware at a specified position before another middleware. The position can be either an index or another middleware.
35
+ #
36
+ # > **_❗️Heads up:_** By default, Rage always uses the `Rage::FiberWrapper` middleware, which wraps every request in a separate fiber. Make sure to always have this middleware in the top of the stack. Placing other middlewares in front may lead to undefined behavior.
37
+ #
38
+ # > ```
39
+ # config.middleware.insert_before Rack::Head, Magical::Unicorns
40
+ # config.middleware.insert_before 0, Magical::Unicorns
41
+ # > ```
42
+ #
43
+ # • _config.middleware.insert_after_
44
+ #
45
+ # > Adds middleware at a specified position after another middleware. The position can be either an index or another middleware.
46
+ #
47
+ # > ```
48
+ # config.middleware.insert_after Rack::Head, Magical::Unicorns
49
+ # > ```
50
+ #
51
+ # # Server Configuration
52
+ #
53
+ # _• config.server.max_clients_
54
+ #
55
+ # > Limits the number of simultaneous connections the server can accept. Defaults to the maximum number of open files.
56
+ #
57
+ # > **_❗️Heads up:_** Decreasing this number is almost never a good idea. Depending on your application specifics, you are encouraged to use other methods to limit the number of concurrent connections:
58
+ #
59
+ # > 1. If your application is exposed to the public, you may want to use a cloud rate limiter, like {https://developers.cloudflare.com/waf Cloudflare WAF} or {https://docs.fastly.com/en/ngwaf Fastly WAF}.
60
+ # > 2. Otherwise, consider using tools like {https://github.com/rack/rack-attack Rack::Attack} or {https://github.com/mperham/connection_pool connection_pool}.
61
+ #
62
+ # > ```
63
+ # # Limit the amount of connections your application can accept
64
+ # config.middleware.use Rack::Attack
65
+ # Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
66
+ # req.ip
67
+ # end
68
+ # #
69
+ # # Limit the amount of connections to a specific resource
70
+ # HTTP = ConnectionPool.new(size: 5, timeout: 5) { Net::HTTP }
71
+ # HTTP.with do |conn|
72
+ # conn.get("/my-resource")
73
+ # end
74
+ # > ```
75
+ #
76
+ # • _config.server.port_
77
+ #
78
+ # > Specifies what port the server will listen on.
79
+ #
80
+ # • _config.server.workers_count_
81
+ #
82
+ # > Specifies the number of server processes to run.
83
+ #
84
+ # • _config.server.timeout_
85
+ #
86
+ # > Specifies connection timeout.
87
+ #
1
88
  class Rage::Configuration
2
- attr_accessor :logger, :log_formatter, :log_level
89
+ attr_accessor :logger
90
+ attr_reader :log_formatter, :log_level
3
91
 
4
92
  # used in DSL
5
93
  def config = self
6
94
 
95
+ def log_formatter=(formatter)
96
+ raise "Custom log formatter should respond to `#call`" unless formatter.respond_to?(:call)
97
+ @log_formatter = formatter
98
+ end
99
+
100
+ def log_level=(level)
101
+ @log_level = level.is_a?(Symbol) ? Logger.const_get(level.to_s.upcase) : level
102
+ end
103
+
7
104
  def server
8
105
  @server ||= Server.new
9
106
  end
10
107
 
108
+ def middleware
109
+ @middleware ||= Middleware.new
110
+ end
111
+
11
112
  class Server
12
- attr_accessor :port, :workers_count
113
+ attr_accessor :port, :workers_count, :timeout, :max_clients
13
114
  attr_reader :threads_count
14
115
 
15
116
  def initialize
@@ -19,9 +120,53 @@ class Rage::Configuration
19
120
  end
20
121
  end
21
122
 
123
+ class Middleware
124
+ attr_reader :middlewares
125
+
126
+ def initialize
127
+ @middlewares = [[Rage::FiberWrapper]]
128
+ end
129
+
130
+ def use(new_middleware, *args, &block)
131
+ insert_after(@middlewares.length - 1, new_middleware, *args, &block)
132
+ end
133
+
134
+ def insert_before(existing_middleware, new_middleware, *args, &block)
135
+ index = find_middleware_index(existing_middleware)
136
+ if index == 0 && @middlewares[0][0] == Rage::FiberWrapper
137
+ puts("Warning: inserting #{new_middleware} before Rage::FiberWrapper may lead to undefined behavior.")
138
+ end
139
+ @middlewares = (@middlewares[0...index] + [[new_middleware, args, block]] + @middlewares[index..]).uniq(&:first)
140
+ end
141
+
142
+ def insert_after(existing_middleware, new_middleware, *args, &block)
143
+ index = find_middleware_index(existing_middleware)
144
+ @middlewares = (@middlewares[0..index] + [[new_middleware, args, block]] + @middlewares[index + 1..]).uniq(&:first)
145
+ end
146
+
147
+ private
148
+
149
+ def find_middleware_index(middleware)
150
+ if middleware.is_a?(Integer)
151
+ if middleware < 0 || middleware >= @middlewares.length
152
+ raise ArgumentError, "Middleware index should be in the (0...#{@middlewares.length}) range"
153
+ end
154
+ middleware
155
+ else
156
+ @middlewares.index { |m, _, _| m == middleware }.tap do |i|
157
+ raise ArgumentError, "Couldn't find #{middleware} in the middleware stack" unless i
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # @private
22
164
  def __finalize
23
- @logger ||= Rage::Logger.new(nil)
24
- @logger.formatter = @log_formatter if @logger && @log_formatter
25
- @logger.level = @log_level if @logger && @log_level
165
+ if @logger
166
+ @logger.formatter = @log_formatter if @log_formatter
167
+ @logger.level = @log_level if @log_level
168
+ else
169
+ @logger = Rage::Logger.new(nil)
170
+ end
26
171
  end
27
172
  end
@@ -8,7 +8,7 @@ class RageController::API
8
8
  # sends a correct response down to the server;
9
9
  # returns the name of the newly defined method;
10
10
  def __register_action(action)
11
- raise "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
11
+ raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
12
12
 
13
13
  before_actions_chunk = if @__before_actions
14
14
  filtered_before_actions = @__before_actions.select do |h|
@@ -25,7 +25,7 @@ class RageController::API
25
25
  "unless #{h[:unless]}"
26
26
  end
27
27
 
28
- <<-RUBY
28
+ <<~RUBY
29
29
  #{h[:name]} #{condition}
30
30
  return [@__status, @__headers, @__body] if @__rendered
31
31
  RUBY
@@ -36,9 +36,34 @@ class RageController::API
36
36
  ""
37
37
  end
38
38
 
39
+ after_actions_chunk = if @__after_actions
40
+ filtered_after_actions = @__after_actions.select do |h|
41
+ (!h[:only] || h[:only].include?(action)) &&
42
+ (!h[:except] || !h[:except].include?(action))
43
+ end
44
+
45
+ lines = filtered_after_actions.map! do |h|
46
+ condition = if h[:if] && h[:unless]
47
+ "if #{h[:if]} && !#{h[:unless]}"
48
+ elsif h[:if]
49
+ "if #{h[:if]}"
50
+ elsif h[:unless]
51
+ "unless #{h[:unless]}"
52
+ end
53
+
54
+ <<~RUBY
55
+ #{h[:name]} #{condition}
56
+ RUBY
57
+ end
58
+
59
+ lines.join("\n")
60
+ else
61
+ ""
62
+ end
63
+
39
64
  rescue_handlers_chunk = if @__rescue_handlers
40
65
  lines = @__rescue_handlers.map do |klasses, handler|
41
- <<-RUBY
66
+ <<~RUBY
42
67
  rescue #{klasses.join(", ")} => __e
43
68
  #{handler}(__e)
44
69
  [@__status, @__headers, @__body]
@@ -50,11 +75,18 @@ class RageController::API
50
75
  ""
51
76
  end
52
77
 
53
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
78
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
54
79
  def __run_#{action}
55
80
  #{before_actions_chunk}
56
81
  #{action}
57
82
 
83
+ #{if !after_actions_chunk.empty?
84
+ <<~RUBY
85
+ @__rendered = true
86
+ #{after_actions_chunk}
87
+ RUBY
88
+ end}
89
+
58
90
  [@__status, @__headers, @__body]
59
91
 
60
92
  #{rescue_handlers_chunk}
@@ -63,13 +95,14 @@ class RageController::API
63
95
  end
64
96
 
65
97
  # @private
66
- attr_writer :__before_actions, :__rescue_handlers
98
+ attr_writer :__before_actions, :__after_actions, :__rescue_handlers
67
99
 
68
100
  # @private
69
101
  # pass the variable down to the child; the child will continue to use it until changes need to be made;
70
102
  # only then the object will be copied; the frozen state communicates that the object is shared with the parent;
71
103
  def inherited(klass)
72
104
  klass.__before_actions = @__before_actions.freeze
105
+ klass.__after_actions = @__after_actions.freeze
73
106
  klass.__rescue_handlers = @__rescue_handlers.freeze
74
107
  end
75
108
 
@@ -148,29 +181,12 @@ class RageController::API
148
181
  # end
149
182
  # @note The block form doesn't receive an argument and is executed on the controller level as if it was a regular method.
150
183
  def before_action(action_name = nil, **opts, &block)
151
- if block_given?
152
- action_name = define_tmp_method(block)
153
- elsif action_name.nil?
154
- raise "No handler provided. Pass the `action_name` parameter or provide a block."
155
- end
156
-
157
- _only, _except, _if, _unless = opts.values_at(:only, :except, :if, :unless)
184
+ action = prepare_action_params(action_name, **opts, &block)
158
185
 
159
186
  if @__before_actions && @__before_actions.frozen?
160
187
  @__before_actions = @__before_actions.dup
161
188
  end
162
189
 
163
- action = {
164
- name: action_name,
165
- only: _only && Array(_only),
166
- except: _except && Array(_except),
167
- if: _if,
168
- unless: _unless
169
- }
170
-
171
- action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
172
- action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
173
-
174
190
  if @__before_actions.nil?
175
191
  @__before_actions = [action]
176
192
  elsif i = @__before_actions.find_index { |a| a[:name] == action_name }
@@ -180,6 +196,22 @@ class RageController::API
180
196
  end
181
197
  end
182
198
 
199
+ def after_action(action_name = nil, **opts, &block)
200
+ action = prepare_action_params(action_name, **opts, &block)
201
+
202
+ if @__after_actions && @__after_actions.frozen?
203
+ @__after_actions = @__after_actions.dup
204
+ end
205
+
206
+ if @__after_actions.nil?
207
+ @__after_actions = [action]
208
+ elsif i = @__after_actions.find_index { |a| a[:name] == action_name }
209
+ @__after_actions[i] = action
210
+ else
211
+ @__after_actions << action
212
+ end
213
+ end
214
+
183
215
  # Prevent a `before_action` hook from running.
184
216
  #
185
217
  # @param action_name [String] the name of the callback to skip
@@ -208,6 +240,32 @@ class RageController::API
208
240
 
209
241
  @__before_actions[i] = action
210
242
  end
243
+
244
+ private
245
+
246
+ # used by `before_action` and `after_action`
247
+ def prepare_action_params(action_name = nil, **opts, &block)
248
+ if block_given?
249
+ action_name = define_tmp_method(block)
250
+ elsif action_name.nil?
251
+ raise "No handler provided. Pass the `action_name` parameter or provide a block."
252
+ end
253
+
254
+ _only, _except, _if, _unless = opts.values_at(:only, :except, :if, :unless)
255
+
256
+ action = {
257
+ name: action_name,
258
+ only: _only && Array(_only),
259
+ except: _except && Array(_except),
260
+ if: _if,
261
+ unless: _unless
262
+ }
263
+
264
+ action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
265
+ action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
266
+
267
+ action
268
+ end
211
269
  end # class << self
212
270
 
213
271
  # @private
data/lib/rage/env.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Env
4
+ STANDARD_ENVS = %w(development test staging production)
5
+
6
+ def initialize(env)
7
+ @env = env
8
+
9
+ STANDARD_ENVS.each do |standard_env|
10
+ self.class.define_method("#{standard_env}?") { false } if standard_env != @env
11
+ end
12
+ self.class.define_method("#{@env}?") { true }
13
+ end
14
+
15
+ def method_missing(method_name, *, &)
16
+ method_name.end_with?("?") ? false : super
17
+ end
18
+
19
+ def respond_to_missing?(method_name, include_private = false)
20
+ method_name.end_with?("?")
21
+ end
22
+
23
+ def ==(other)
24
+ @env == other
25
+ end
26
+
27
+ def to_sym
28
+ @env.to_sym
29
+ end
30
+
31
+ def to_s
32
+ @env
33
+ end
34
+ end
data/lib/rage/errors.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  module Rage::Errors
2
2
  class BadRequest < StandardError
3
3
  end
4
+
5
+ class RouterError < StandardError
6
+ end
4
7
  end
data/lib/rage/fiber.rb CHANGED
@@ -23,6 +23,24 @@ class Fiber
23
23
  @__err
24
24
  end
25
25
 
26
+ # @private
27
+ def __get_id
28
+ @__rage_id ||= object_id.to_s
29
+ end
30
+
31
+ # @private
32
+ def __yielded?
33
+ !@__rage_id.nil?
34
+ end
35
+
36
+ # @private
37
+ def __block_channel(force = false)
38
+ @__block_channel_i ||= 0
39
+ @__block_channel_i += 1 if force
40
+
41
+ "block:#{object_id}:#{@__block_channel_i}"
42
+ end
43
+
26
44
  # @private
27
45
  # pause a fiber and resume in the next iteration of the event loop
28
46
  def self.pause
@@ -81,7 +81,7 @@ class Rage::FiberScheduler
81
81
  end
82
82
 
83
83
  def block(_blocker, timeout = nil)
84
- f, fulfilled, channel = Fiber.current, false, "unblock:#{Fiber.current.object_id}"
84
+ f, fulfilled, channel = Fiber.current, false, Fiber.current.__block_channel(true)
85
85
 
86
86
  resume_fiber_block = proc do
87
87
  unless fulfilled
@@ -100,7 +100,7 @@ class Rage::FiberScheduler
100
100
  end
101
101
 
102
102
  def unblock(_blocker, fiber)
103
- ::Iodine.publish("unblock:#{fiber.object_id}", "")
103
+ ::Iodine.publish(fiber.__block_channel, "")
104
104
  end
105
105
 
106
106
  def fiber(&block)
@@ -56,8 +56,8 @@ class Rage::Logger
56
56
  # @param shift_period_suffix [String] the log file suffix format for daily, weekly or monthly rotation
57
57
  # @param binmode sets whether the logger writes in binary mode
58
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:)
59
+ @logdev = if log && log != File::NULL
60
+ Logger::LogDevice.new(log, shift_age:, shift_size:, shift_period_suffix:, binmode:)
61
61
  end
62
62
 
63
63
  @formatter = formatter
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Cors
4
+ def initialize(app, *, &)
5
+ @app = app
6
+ instance_eval(&)
7
+ end
8
+
9
+ def call(env)
10
+ if env["REQUEST_METHOD"] == "OPTIONS"
11
+ return (response = @cors_response)
12
+ end
13
+
14
+ response = @app.call(env)
15
+ response[1]["Access-Control-Allow-Credentials"] = @allow_credentials if @allow_credentials
16
+ response[1]["Access-Control-Expose-Headers"] = @expose_headers if @expose_headers
17
+
18
+ response
19
+ ensure
20
+ if origin = @cors_check.call(env)
21
+ headers = response[1]
22
+ headers["Access-Control-Allow-Origin"] = origin
23
+ if @origins != "*"
24
+ vary = headers["Vary"]
25
+ if vary.nil?
26
+ headers["Vary"] = "Origin"
27
+ elsif vary != "Origin"
28
+ headers["Vary"] += ", Origin"
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ # Set CORS rules for the application.
35
+ #
36
+ # @param origins [String, Regexp, "*"] origins allowed to access the application
37
+ # @param methods [Array<Symbol>, "*"] allowed methods when accessing the application
38
+ # @param allow_headers [Array<String>, "*"] indicate which HTTP headers can be used when making the actual request
39
+ # @param expose_headers [Array<String>, "*"] adds the specified headers to the allowlist that JavaScript in browsers is allowed to access
40
+ # @param max_age [Integer] indicate how long the results of a preflight request can be cached
41
+ # @param allow_credentials [Boolean] indicate whether or not the response to the request can be exposed when the `credentials` flag is `true`
42
+ # @example
43
+ # config.middleware.use Rage::Cors do
44
+ # allow "localhost:5173", "myhost.com"
45
+ # end
46
+ # @example
47
+ # config.middleware.use Rage::Cors do
48
+ # allow "*",
49
+ # methods: [:get, :post, :put],
50
+ # allow_headers: ["x-domain-token"],
51
+ # expose: ["Some-Custom-Response-Header"],
52
+ # max_age: 600
53
+ # end
54
+ # @note The middleware only supports the basic case of allowing one or several origins for the whole application. Use {https://github.com/cyu/rack-cors Rack::Cors} if you are looking to specify more advanced rules.
55
+ def allow(*origins, methods: "*", allow_headers: "*", expose_headers: nil, max_age: nil, allow_credentials: false)
56
+ @allow_headers = Array(allow_headers).join(", ") if allow_headers
57
+ @expose_headers = Array(expose_headers).join(", ") if expose_headers
58
+ @max_age = max_age.to_s if max_age
59
+ @allow_credentials = "true" if allow_credentials
60
+
61
+ @default_methods = %w(GET POST PUT PATCH DELETE HEAD OPTIONS)
62
+ @methods = if methods != "*"
63
+ methods.map! { |method| method.to_s.upcase }.tap { |m|
64
+ if (invalid_methods = m - @default_methods).any?
65
+ raise "Unsupported method passed to Rage::Cors: #{invalid_methods[0]}"
66
+ end
67
+ }.join(", ")
68
+ elsif @allow_credentials
69
+ @default_methods.join(", ")
70
+ else
71
+ "*"
72
+ end
73
+
74
+ if @allow_credentials
75
+ raise "Rage::Cors requires you to explicitly list allowed headers when using `allow_credentials: true`" if @allow_headers == "*"
76
+ raise "Rage::Cors requires you to explicitly list exposed headers when using `allow_credentials: true`" if @expose_headers == "*"
77
+ end
78
+
79
+ @origins = []
80
+ origins.each do |origin|
81
+ if origin == "*"
82
+ @origins = "*"
83
+ break
84
+ elsif origin.is_a?(Regexp) || origin =~ /^\S+:\/\//
85
+ @origins << origin
86
+ else
87
+ @origins << "https://#{origin}" << "http://#{origin}"
88
+ end
89
+ end
90
+
91
+ @cors_check = create_cors_proc
92
+ @cors_response = [204, create_headers, []]
93
+ end
94
+
95
+ private
96
+
97
+ def create_headers
98
+ headers = {
99
+ "Access-Control-Allow-Origin" => "",
100
+ "Access-Control-Allow-Methods" => @methods,
101
+ }
102
+
103
+ if @allow_headers
104
+ headers["Access-Control-Allow-Headers"] = @allow_headers
105
+ end
106
+ if @expose_headers
107
+ headers["Access-Control-Expose-Headers"] = @expose_headers
108
+ end
109
+ if @max_age
110
+ headers["Access-Control-Max-Age"] = @max_age
111
+ end
112
+ if @allow_credentials
113
+ headers["Access-Control-Allow-Credentials"] = @allow_credentials
114
+ end
115
+
116
+ headers
117
+ end
118
+
119
+ def create_cors_proc
120
+ if @origins == "*"
121
+ ->(env) { env["HTTP_ORIGIN"] }
122
+ else
123
+ origins_eval = @origins.map { |origin|
124
+ origin.is_a?(Regexp) ?
125
+ "origin =~ /#{origin.source}/.freeze" :
126
+ "origin == '#{origin}'.freeze"
127
+ }.join(" || ")
128
+
129
+ eval <<-RUBY
130
+ ->(env) do
131
+ origin = env["HTTP_ORIGIN".freeze]
132
+ origin if #{origins_eval}
133
+ end
134
+ RUBY
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The middleware wraps every request in a separate Fiber. It should always be on the top of the middleware stack,
5
+ # as it implements a custom defer protocol, which may break middlewares located above.
6
+ #
7
+ class Rage::FiberWrapper
8
+ def initialize(app)
9
+ Iodine.on_state(:on_start) do
10
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
11
+ end
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ fiber = Fiber.schedule do
17
+ @app.call(env)
18
+ ensure
19
+ # notify Iodine the request can now be resumed
20
+ Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS) if Fiber.current.__yielded?
21
+ end
22
+
23
+ # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request
24
+ if fiber.alive?
25
+ [:__http_defer__, fiber]
26
+ else
27
+ fiber.__get_result
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Reloader
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ Rage.code_loader.reload
10
+ @app.call(env)
11
+ rescue Exception => e
12
+ exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}"
13
+ puts(exception_str)
14
+ [500, {}, [exception_str]]
15
+ end
16
+ end
@@ -14,6 +14,11 @@ class Rage::Router::Backend
14
14
  @constrainer = Rage::Router::Constrainer.new({})
15
15
  end
16
16
 
17
+ def reset_routes
18
+ @routes = []
19
+ @trees = {}
20
+ end
21
+
17
22
  def mount(path, handler, methods)
18
23
  raise "Mount handler should respond to `call`" unless handler.respond_to?(:call)
19
24
 
@@ -79,6 +84,9 @@ class Rage::Router::Backend
79
84
  end
80
85
 
81
86
  __on(method, path, handler, constraints, defaults, meta)
87
+
88
+ rescue Rage::Errors::RouterError => e
89
+ raise e unless Rage.code_loader.reloading?
82
90
  end
83
91
 
84
92
  def lookup(env)
@@ -280,7 +288,7 @@ class Rage::Router::Backend
280
288
  if Object.const_defined?(klass)
281
289
  Object.const_get(klass)
282
290
  else
283
- raise "Routing error: could not find the #{klass} class"
291
+ raise Rage::Errors::RouterError, "Routing error: could not find the #{klass} class"
284
292
  end
285
293
  end
286
294
  end
data/lib/rage/setup.rb CHANGED
@@ -2,22 +2,10 @@ Iodine.patch_rack
2
2
 
3
3
  require_relative "#{Rage.root}/config/environments/#{Rage.env}"
4
4
 
5
+ # Run application initializers
6
+ Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initializer) }
5
7
 
6
- # load application files
7
- app, bad = Dir["#{Rage.root}/app/**/*.rb"], []
8
-
9
- loop do
10
- path = app.shift
11
- break if path.nil?
12
-
13
- require_relative path
14
-
15
- # push the file to the end of the list in case it depends on another file that has not yet been required;
16
- # re-raise if only errored out files are left
17
- rescue NameError
18
- raise if (app - bad).empty?
19
- app << path
20
- bad << path
21
- end
8
+ # Load application classes
9
+ Rage.code_loader.setup
22
10
 
23
11
  require_relative "#{Rage.root}/config/routes"
@@ -7,4 +7,6 @@ Rage.configure do
7
7
 
8
8
  # Specify the logger
9
9
  config.logger = Rage::Logger.new(STDOUT)
10
+
11
+ config.middleware.use Rage::Reloader
10
12
  end
File without changes
@@ -1,3 +1,4 @@
1
1
  require_relative "config/application"
2
2
 
3
3
  run Rage.application
4
+ Rage.load_middlewares(self)
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.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -28,7 +28,7 @@ module Rage
28
28
  end
29
29
 
30
30
  def self.env
31
- @__env ||= ENV["RAGE_ENV"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
31
+ @__env ||= Rage::Env.new(ENV["RAGE_ENV"])
32
32
  end
33
33
 
34
34
  def self.groups
@@ -43,6 +43,16 @@ module Rage
43
43
  @logger ||= config.logger
44
44
  end
45
45
 
46
+ def self.load_middlewares(rack_builder)
47
+ config.middleware.middlewares.each do |middleware, args, block|
48
+ rack_builder.use(middleware, *args, &block)
49
+ end
50
+ end
51
+
52
+ def self.code_loader
53
+ @code_loader ||= Rage::CodeLoader.new
54
+ end
55
+
46
56
  module Router
47
57
  module Strategies
48
58
  end
@@ -51,3 +61,5 @@ end
51
61
 
52
62
  module RageController
53
63
  end
64
+
65
+ require_relative "rage/env"
data/rage.gemspec CHANGED
@@ -29,5 +29,6 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency "thor", "~> 1.0"
31
31
  spec.add_dependency "rack", "~> 2.0"
32
- spec.add_dependency "rage-iodine", "~> 2.2"
32
+ spec.add_dependency "rage-iodine", "~> 3.0"
33
+ spec.add_dependency "zeitwerk", "~> 2.6"
33
34
  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.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-12-01 00:00:00.000000000 Z
11
+ date: 2023-12-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -44,14 +44,28 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '2.2'
47
+ version: '3.0'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '2.2'
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: zeitwerk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.6'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.6'
55
69
  description:
56
70
  email:
57
71
  - rsamoi@icloud.com
@@ -74,13 +88,18 @@ files:
74
88
  - lib/rage/all.rb
75
89
  - lib/rage/application.rb
76
90
  - lib/rage/cli.rb
91
+ - lib/rage/code_loader.rb
77
92
  - lib/rage/configuration.rb
78
93
  - lib/rage/controller/api.rb
94
+ - lib/rage/env.rb
79
95
  - lib/rage/errors.rb
80
96
  - lib/rage/fiber.rb
81
97
  - lib/rage/fiber_scheduler.rb
82
98
  - lib/rage/logger/logger.rb
83
99
  - lib/rage/logger/text_formatter.rb
100
+ - lib/rage/middleware/cors.rb
101
+ - lib/rage/middleware/fiber_wrapper.rb
102
+ - lib/rage/middleware/reloader.rb
84
103
  - lib/rage/params_parser.rb
85
104
  - lib/rage/request.rb
86
105
  - lib/rage/router/README.md
@@ -99,6 +118,7 @@ files:
99
118
  - lib/rage/templates/config-environments-development.rb
100
119
  - lib/rage/templates/config-environments-production.rb
101
120
  - lib/rage/templates/config-environments-test.rb
121
+ - lib/rage/templates/config-initializers-.keep
102
122
  - lib/rage/templates/config-routes.rb
103
123
  - lib/rage/templates/config.ru
104
124
  - lib/rage/templates/lib-.keep