rage-rb 1.26.1 → 1.28.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.
data/lib/rage/cli.rb CHANGED
@@ -4,65 +4,14 @@ require "thor"
4
4
  require "rack"
5
5
  require "rage/version"
6
6
 
7
+ require "rage/cli/base"
7
8
  require "rage/cli/skills"
9
+ require "rage/cli/openapi"
10
+ require "rage/cli/code_generator"
11
+ require "rage/cli/new_app_generator"
8
12
 
9
- module Rage
10
- class CLICodeGenerator < Thor
11
- include Thor::Actions
12
-
13
- def self.source_root
14
- File.expand_path("templates", __dir__)
15
- end
16
-
17
- desc "migration NAME", "Generate a new migration"
18
- def migration(name = nil)
19
- return help("migration") if name.nil?
20
-
21
- setup
22
- Rake::Task["db:new_migration"].invoke(name)
23
- end
24
-
25
- desc "model NAME", "Generate a new model"
26
- def model(name = nil)
27
- return help("model") if name.nil?
28
-
29
- setup
30
- migration("create_#{name.pluralize}")
31
- @model_name = name.classify
32
- template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb")
33
- end
34
-
35
- desc "controller NAME", "Generate a new controller"
36
- def controller(name = nil)
37
- return help("controller") if name.nil?
38
-
39
- setup
40
- unless defined?(ActiveSupport::Inflector)
41
- raise LoadError, <<~ERR
42
- ActiveSupport::Inflector is required to run this command. Add the following line to your Gemfile:
43
- gem "activesupport", require: "active_support/inflector"
44
- ERR
45
- end
46
-
47
- # remove trailing Controller if already present
48
- normalized_name = name.sub(/_?controller$/i, "")
49
- @controller_name = "#{normalized_name.camelize}Controller"
50
- file_name = "#{normalized_name.underscore}_controller.rb"
51
-
52
- template("controller-template/controller.rb", "app/controllers/#{file_name}")
53
- end
54
-
55
- private
56
-
57
- def setup
58
- @setup ||= begin
59
- require "rake"
60
- load "Rakefile"
61
- end
62
- end
63
- end
64
-
65
- class CLI < Thor
13
+ module Rage::CLI
14
+ class App < Base
66
15
  def self.exit_on_failure?
67
16
  true
68
17
  end
@@ -74,7 +23,7 @@ module Rage
74
23
  return help("new") if options.help? || path.nil?
75
24
 
76
25
  require "rage/all"
77
- CLINewAppGenerator.start([path, options[:database]])
26
+ NewAppGenerator.start([path, options[:database]])
78
27
  end
79
28
 
80
29
  desc "s", "Start the app server"
@@ -232,11 +181,14 @@ module Rage
232
181
  end
233
182
 
234
183
  desc "skills", "Manage coding agent skills"
235
- subcommand "skills", CLISkills
184
+ subcommand "skills", Skills
185
+
186
+ desc "openapi", "OpenAPI validation tools"
187
+ subcommand "openapi", OpenAPI
236
188
 
237
189
  map "generate" => :g
238
190
  desc "g TYPE", "Generate new code"
239
- subcommand "g", CLICodeGenerator
191
+ subcommand "g", CodeGenerator
240
192
 
241
193
  map "--tasks" => :tasks
242
194
  desc "--tasks", "See the list of available tasks"
@@ -273,26 +225,6 @@ module Rage
273
225
 
274
226
  private
275
227
 
276
- def environment
277
- require File.expand_path("config/application.rb", Dir.pwd)
278
-
279
- if Rage.config.internal.rails_mode
280
- require File.expand_path("config/environment.rb", Dir.pwd)
281
- end
282
- end
283
-
284
- def set_env(options)
285
- if options[:environment]
286
- ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = options[:environment]
287
- elsif ENV["RAGE_ENV"]
288
- ENV["RAILS_ENV"] = ENV["RAGE_ENV"]
289
- elsif ENV["RAILS_ENV"]
290
- ENV["RAGE_ENV"] = ENV["RAILS_ENV"]
291
- else
292
- ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = "development"
293
- end
294
- end
295
-
296
228
  def linked_rake_tasks
297
229
  require "rake"
298
230
  Rake::TaskManager.record_task_metadata = true
@@ -371,67 +303,4 @@ module Rage
371
303
  end
372
304
  end
373
305
  end
374
-
375
- class CLINewAppGenerator < Thor::Group
376
- include Thor::Actions
377
- argument :path, type: :string
378
- argument :database, type: :string, required: false
379
-
380
- def self.source_root
381
- File.expand_path("templates", __dir__)
382
- end
383
-
384
- def setup
385
- @use_database = !database.nil?
386
- end
387
-
388
- def create_directory
389
- empty_directory(path)
390
- end
391
-
392
- def copy_files
393
- inject_templates
394
- end
395
-
396
- def install_database
397
- return unless @use_database
398
-
399
- @app_name = path.tr("-", "_").downcase
400
- append_to_file "#{path}/Gemfile", <<~RUBY
401
-
402
- gem "#{get_db_gem_name}"
403
- gem "activerecord"
404
- gem "standalone_migrations", require: false
405
- RUBY
406
-
407
- inject_templates("db-templates")
408
- inject_templates("db-templates/#{database}")
409
- end
410
-
411
- private
412
-
413
- def inject_templates(from = nil)
414
- root = "#{self.class.source_root}/#{from}"
415
-
416
- Dir.glob("*", base: root).each do |template|
417
- next if File.directory?("#{root}/#{template}")
418
-
419
- *template_path_parts, template_name = template.split("-")
420
- template("#{root}/#{template}", [path, *template_path_parts, template_name].join("/"))
421
- end
422
- end
423
-
424
- def get_db_gem_name
425
- case database
426
- when "mysql"
427
- "mysql2"
428
- when "trilogy"
429
- "trilogy"
430
- when "postgresql"
431
- "pg"
432
- when "sqlite3"
433
- "sqlite3"
434
- end
435
- end
436
- end
437
306
  end
@@ -37,6 +37,7 @@ class Rage::CodeLoader
37
37
  load("#{Rage.root}/config/routes.rb")
38
38
 
39
39
  reload_components
40
+ Rage.config.run_hooks_for(:after_reload)
40
41
  end
41
42
 
42
43
  # in Rails mode - reset the routes; everything else will be done by Rails
@@ -47,6 +48,7 @@ class Rage::CodeLoader
47
48
  Rage.__router.reset_routes
48
49
 
49
50
  reload_components
51
+ Rage.config.run_hooks_for(:after_reload)
50
52
  end
51
53
 
52
54
  def reloading?
@@ -54,7 +56,9 @@ class Rage::CodeLoader
54
56
  end
55
57
 
56
58
  def check_updated!
57
- current_watched = @autoload_path.glob("**/*.rb") + Rage.root.glob("config/routes.rb") + Rage.root.glob("config/openapi_components.*")
59
+ current_watched = @autoload_path.glob("**/*.{rb,erb}") + Rage.root.glob("config/routes.rb") + Rage.root.glob("config/openapi_components.*")
60
+ current_watched += Rage.config.code_loader.reload_paths.flat_map { |pattern| Rage.root.glob(pattern) }
61
+
58
62
  current_update_at = current_watched.max_by { |path| path.exist? ? path.mtime.to_f : 0 }&.mtime.to_f
59
63
  return false if !@last_watched && !@last_update_at
60
64
 
@@ -23,6 +23,7 @@ require "erb"
23
23
  # - _RAGE_DISABLE_IO_WRITE_ - disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
24
24
  # - _RAGE_DISABLE_AR_POOL_PATCH_ - disables the `ActiveRecord::ConnectionPool` patch and makes Rage use the original ActiveRecord implementation.
25
25
  # - _RAGE_DISABLE_AR_WEAK_CONNECTIONS_ - instructs Rage to not reuse Active Record connections between different fibers. Only applies to Active Record < 7.2.
26
+ # - _RAGE_ENABLE_NON_BLOCKING_TIMEOUT_ - enables the non-blocking `timeout_after` method on the fiber scheduler, which allows timeouts to work cooperatively with fiber-based concurrency.
26
27
  #
27
28
  class Rage::Configuration
28
29
  # @private
@@ -140,6 +141,15 @@ class Rage::Configuration
140
141
  push_hook(block, :after_initialize)
141
142
  end
142
143
 
144
+ # Schedule a block of code to run after Rage has reloaded the application code in development. Use this to reset state or re-initialize dependencies that cache application-level constants.
145
+ # @example
146
+ # Rage.config.after_reload do
147
+ # MyCache.clear
148
+ # end
149
+ def after_reload(&block)
150
+ push_hook(block, :after_reload)
151
+ end
152
+
143
153
  # Register a custom renderer that generates overloads `render` on all controllers.
144
154
  # The block receives the object passed to `render` together with any additional keyword arguments.
145
155
  # The code inside the block is executed in the context of the controller instance, so you can access all usual controller methods in it.
@@ -255,6 +265,29 @@ class Rage::Configuration
255
265
  def log_tags
256
266
  @log_tags ||= LogTags.new
257
267
  end
268
+
269
+ # Allows configuring case-insensitive partial matches for redacting structured log context keys.
270
+ # Matching keys will have their values replaced with `"[REDACTED]"` before the log entry is written.
271
+ #
272
+ # @param keys [String, Symbol, Array<String, Symbol>, nil] one or more keys to redact
273
+ # @example Redact common secrets from structured logs
274
+ # Rage.configure do
275
+ # config.log_redact_keys = [:password, :token, :secret]
276
+ # end
277
+ def log_redact_keys=(keys)
278
+ @log_redact_keys = Array(keys).filter_map { |key|
279
+ if !key.is_a?(String) && !key.is_a?(Symbol)
280
+ raise ArgumentError, "log redact keys have to be strings or symbols"
281
+ elsif !key.empty?
282
+ key.to_s
283
+ end
284
+ }.uniq
285
+ end
286
+
287
+ # @private
288
+ def log_redact_keys
289
+ @log_redact_keys || []
290
+ end
258
291
  # @!endgroup
259
292
 
260
293
  # @!group Telemetry Configuration
@@ -281,6 +314,14 @@ class Rage::Configuration
281
314
  end
282
315
  # @!endgroup
283
316
 
317
+ # @!group Code Loader Configuration
318
+ # Allows configuring code loader settings.
319
+ # @return [Rage::Configuration::CodeLoader]
320
+ def code_loader
321
+ @code_loader ||= CodeLoader.new
322
+ end
323
+ # @!endgroup
324
+
284
325
  # @!group Blocking Operation Pool Configuration
285
326
  # Allows configuring the thread pool for offloading native calls.
286
327
  # @return [Rage::Configuration::BlockingOperationPool]
@@ -434,7 +475,7 @@ class Rage::Configuration
434
475
  # end
435
476
  def <<(reporter)
436
477
  validate_input!(reporter)
437
- return self if @objects.include?(reporter)
478
+ raise ArgumentError, "#{reporter} is already registered" if @objects.include?(reporter)
438
479
 
439
480
  @objects << reporter
440
481
  Rage::Errors.__send__(:__register_reporter, reporter)
@@ -877,6 +918,7 @@ class Rage::Configuration
877
918
  @backend_options = parse_disk_backend_options(opts)
878
919
  Rage::Deferred::Backends::Disk
879
920
  when nil
921
+ @backend_options = {} if RUBY_VERSION.start_with?("3.3.")
880
922
  Rage::Deferred::Backends::Nil
881
923
  else
882
924
  raise ArgumentError, "unsupported backend value; supported keys are `:disk` and `nil`"
@@ -1122,6 +1164,29 @@ class Rage::Configuration
1122
1164
  attr_accessor :form_actions
1123
1165
  end
1124
1166
 
1167
+ class CodeLoader
1168
+ # @private
1169
+ def initialize
1170
+ @reload_paths = []
1171
+ end
1172
+
1173
+ # @private
1174
+ attr_reader :reload_paths
1175
+
1176
+ # Specify additional paths to watch for changes in development.
1177
+ #
1178
+ # @param paths [Array<Pathname, String>] glob patterns or directory paths to watch
1179
+ # @example Watch HAML templates
1180
+ # Rage.configure do
1181
+ # config.code_loader.reload_paths = ["app/views/**/*.haml"]
1182
+ # end
1183
+ def reload_paths=(paths)
1184
+ @reload_paths = Array(paths).map do |path|
1185
+ path.is_a?(Pathname) ? path : Pathname.new(path)
1186
+ end
1187
+ end
1188
+ end
1189
+
1125
1190
  class BlockingOperationPool
1126
1191
  # @!attribute enabled
1127
1192
  # Enable a background thread pool for offloading native calls that can be executed outside the GVL, freeing
@@ -1164,7 +1229,11 @@ class Rage::Configuration
1164
1229
  # end
1165
1230
  def <<(daemon)
1166
1231
  validate!(daemon)
1232
+ raise ArgumentError, "#{daemon} is already registered" if @klasses.include?(daemon)
1233
+
1167
1234
  @klasses << daemon
1235
+
1236
+ self
1168
1237
  end
1169
1238
 
1170
1239
  alias_method :push, :<<
@@ -1259,12 +1328,14 @@ class Rage::Configuration
1259
1328
  if @logger
1260
1329
  @logger.formatter = @log_formatter if @log_formatter
1261
1330
  @logger.level = @log_level if @log_level
1331
+ @logger.log_redact_keys = @log_redact_keys if @log_redact_keys
1262
1332
  else
1263
1333
  @logger = Rage::Logger.new(nil)
1264
1334
  end
1265
1335
 
1266
- if @log_formatter && @logger.external_logger.is_a?(Rage::Logger::External::Dynamic)
1336
+ if @log_formatter && @logger.external_logger.is_a?(Rage::Logger::External::Dynamic) && !@log_formatter_warning_shown
1267
1337
  puts "WARNING: changing the log formatter via `config.log_formatter=` has no effect when using a custom external logger."
1338
+ @log_formatter_warning_shown = true
1268
1339
  end
1269
1340
 
1270
1341
  if @log_context
data/lib/rage/cookies.rb CHANGED
@@ -278,24 +278,22 @@ class Rage::Cookies
278
278
 
279
279
  private
280
280
 
281
- def ensure_rbnacl!(purpose:)
281
+ def ensure_rbnacl!
282
282
  return if defined?(RbNaCl) &&
283
283
  Gem::Version.create(RbNaCl::VERSION) >= RBNACL_MIN_VERSION &&
284
284
  Gem::Version.create(RbNaCl::VERSION) < RBNACL_MAX_VERSION
285
285
 
286
286
  fail <<~ERR
287
287
 
288
- Rage depends on `rbnacl` [>= #{RBNACL_MIN_VERSION}, < #{RBNACL_MAX_VERSION}] to support #{purpose}. Ensure the following line is added to your Gemfile:
288
+ Rage depends on `rbnacl` [>= #{RBNACL_MIN_VERSION}, < #{RBNACL_MAX_VERSION}] to support encrypted and signed cookies. Ensure the following line is added to your Gemfile:
289
289
  gem "rbnacl"
290
290
 
291
291
  ERR
292
292
  end
293
293
 
294
294
  def build_key(secret, purpose:)
295
- ensure_rbnacl!(purpose: purpose)
296
-
297
295
  if !secret
298
- raise "Rage.config.secret_key_base should be set to use #{purpose}"
296
+ raise "Rage.config.secret_key_base should be set to use encrypted or signed cookies"
299
297
  end
300
298
 
301
299
  RbNaCl::Hash.blake2b("", key: [secret].pack("H*"), digest_size: 32, personal: purpose)
@@ -337,7 +335,10 @@ class Rage::Cookies
337
335
  private
338
336
 
339
337
  def primary_box
340
- @primary_box ||= RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base, purpose: PURPOSE))
338
+ @primary_box ||= begin
339
+ ensure_rbnacl!
340
+ RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base, purpose: PURPOSE))
341
+ end
341
342
  end
342
343
 
343
344
  def fallback_boxes
@@ -401,9 +402,10 @@ class Rage::Cookies
401
402
  end
402
403
 
403
404
  def primary_signer
404
- @primary_signer ||= RbNaCl::HMAC::SHA512256.new(
405
- build_key(Rage.config.secret_key_base, purpose: PURPOSE)
406
- )
405
+ @primary_signer ||= begin
406
+ ensure_rbnacl!
407
+ RbNaCl::HMAC::SHA512256.new(build_key(Rage.config.secret_key_base, purpose: PURPOSE))
408
+ end
407
409
  end
408
410
 
409
411
  def fallback_signers
data/lib/rage/daemon.rb CHANGED
@@ -174,7 +174,6 @@ class Rage::Daemon
174
174
  instance = new
175
175
  result = instance.perform
176
176
  break if result.equal?(Stop) || @__stopping
177
- Rage.logger.warn("Daemon exited, restarting...")
178
177
  rescue => e
179
178
  break if @__stopping
180
179
  Rage.logger.error("Daemon failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
@@ -185,7 +184,11 @@ class Rage::Daemon
185
184
 
186
185
  # reset backoff if ran successfully for a while
187
186
  backoff = INITIAL_BACKOFF if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at > BACKOFF_RESET_INTERVAL
188
- sleep(backoff / 2 + rand * backoff / 2)
187
+
188
+ interval = (backoff / 2 + rand * backoff / 2).round(2)
189
+ Rage.logger.warn("Daemon exited, restarting in #{interval}s")
190
+ sleep(interval)
191
+
189
192
  backoff = (backoff * 2).clamp(INITIAL_BACKOFF, MAX_BACKOFF)
190
193
  end
191
194
  end