rage-rb 1.15.1 → 1.17.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.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Deferred::Proxy
4
+ class Wrapper
5
+ include Rage::Deferred::Task
6
+
7
+ def perform(instance, method_name, *, **)
8
+ instance.public_send(method_name, *, **)
9
+ end
10
+ end
11
+
12
+ def initialize(instance, delay: nil, delay_until: nil)
13
+ @instance = instance
14
+
15
+ @delay = delay
16
+ @delay_until = delay_until
17
+ end
18
+
19
+ def method_missing(method_name, *, **)
20
+ if @instance.respond_to?(method_name)
21
+ self.class.define_method(method_name) do |*args, **kwargs|
22
+ Wrapper.enqueue(@instance, method_name, *args, delay: @delay, delay_until: @delay_until, **kwargs)
23
+ end
24
+
25
+ send(method_name, *, **)
26
+ else
27
+ @instance.public_send(method_name, *, **)
28
+ end
29
+ end
30
+
31
+ def respond_to_missing?(method_name, _)
32
+ @instance.respond_to?(method_name)
33
+ end
34
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Deferred::Queue
4
+ attr_reader :backlog_size
5
+
6
+ def initialize(backend)
7
+ @backend = backend
8
+ @backlog_size = 0
9
+ @backpressure = Rage.config.deferred.backpressure
10
+ end
11
+
12
+ # Write the task to the storage and schedule it for execution.
13
+ def enqueue(task_metadata, delay: nil, delay_until: nil, task_id: nil)
14
+ apply_backpressure if @backpressure
15
+
16
+ publish_in, publish_at = if delay
17
+ delay_i = delay.to_i
18
+ [delay_i, Time.now.to_i + delay_i] if delay_i > 0
19
+ elsif delay_until
20
+ delay_until_i, current_time_i = delay_until.to_i, Time.now.to_i
21
+ [delay_until_i - current_time_i, delay_until_i] if delay_until_i > current_time_i
22
+ end
23
+
24
+ persisted_task_id = @backend.add(task_metadata, publish_at:, task_id:)
25
+ schedule(persisted_task_id, task_metadata, publish_in:)
26
+ end
27
+
28
+ # Schedule the task for execution.
29
+ def schedule(task_id, task_metadata, publish_in: nil)
30
+ publish_in_ms = publish_in.to_i * 1_000 if publish_in && publish_in > 0
31
+ task = Rage::Deferred::Metadata.get_task(task_metadata)
32
+ @backlog_size += 1 unless publish_in_ms
33
+
34
+ Iodine.run_after(publish_in_ms) do
35
+ @backlog_size -= 1 unless publish_in_ms
36
+
37
+ unless Iodine.stopping?
38
+ Fiber.schedule do
39
+ Iodine.task_inc!
40
+
41
+ is_completed = task.new.__perform(task_metadata)
42
+
43
+ if is_completed
44
+ @backend.remove(task_id)
45
+ else
46
+ attempts = Rage::Deferred::Metadata.inc_attempts(task_metadata)
47
+ if task.__should_retry?(attempts)
48
+ enqueue(task_metadata, delay: task.__next_retry_in(attempts), task_id:)
49
+ else
50
+ @backend.remove(task_id)
51
+ end
52
+ end
53
+
54
+ ensure
55
+ Iodine.task_dec!
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def apply_backpressure
64
+ if @backlog_size > @backpressure.high_water_mark && !Fiber[:rage_backpressure_applied]
65
+ Fiber[:rage_backpressure_applied] = true
66
+
67
+ i, target_backlog_size = 0, @backpressure.low_water_mark
68
+ while @backlog_size > target_backlog_size && i < @backpressure.timeout_iterations
69
+ sleep @backpressure.sleep_interval
70
+ i += 1
71
+ end
72
+
73
+ if i == @backpressure.timeout_iterations
74
+ raise Rage::Deferred::PushTimeout, "could not enqueue deferred task within #{@backpressure.timeout} seconds"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # `Rage::Deferred::Task` is a module that should be included in classes that represent tasks to be executed
5
+ # in the background by the `Rage::Deferred` queue. It ensures the tasks can be retried in case of a failure,
6
+ # with a maximum number of attempts and an exponential backoff strategy.
7
+ #
8
+ # To define a task, include the module and implement the `#perform` method:
9
+ #
10
+ # ```ruby
11
+ # class ProcessImage
12
+ # include Rage::Deferred::Task
13
+ #
14
+ # def perform(image_path:)
15
+ # # logic to process the image
16
+ # end
17
+ # end
18
+ # ```
19
+ #
20
+ # The task can be enqueued using the `enqueue` method:
21
+ #
22
+ # ```ruby
23
+ # ProcessImage.enqueue(image_path: 'path/to/image.jpg')
24
+ # ```
25
+ #
26
+ # The `delay` and `delay_until` options can be used to specify when the task should be executed.
27
+ #
28
+ # ```ruby
29
+ # ProcessImage.enqueue(image_path: 'path/to/image.jpg', delay: 10) # delays execution by 10 seconds
30
+ # ProcessImage.enqueue(image_path: 'path/to/image.jpg', delay_until: Time.now + 3600) # executes after 1 hour
31
+ # ```
32
+ #
33
+ module Rage::Deferred::Task
34
+ MAX_ATTEMPTS = 5
35
+ private_constant :MAX_ATTEMPTS
36
+
37
+ BACKOFF_INTERVAL = 5
38
+ private_constant :BACKOFF_INTERVAL
39
+
40
+ def perform
41
+ end
42
+
43
+ # @private
44
+ def __with_optional_log_tag(tag)
45
+ if tag
46
+ Rage.logger.tagged(tag) { yield }
47
+ else
48
+ yield
49
+ end
50
+ end
51
+
52
+ # @private
53
+ def __perform(metadata)
54
+ args = Rage::Deferred::Metadata.get_args(metadata)
55
+ kwargs = Rage::Deferred::Metadata.get_kwargs(metadata)
56
+ attempts = Rage::Deferred::Metadata.get_attempts(metadata)
57
+ request_id = Rage::Deferred::Metadata.get_request_id(metadata)
58
+
59
+ context = { task: self.class.name }
60
+ context[:attempt] = attempts + 1 if attempts
61
+
62
+ Rage.logger.with_context(context) do
63
+ __with_optional_log_tag(request_id) do
64
+ perform(*args, **kwargs)
65
+ true
66
+ rescue Exception => e
67
+ Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
68
+ false
69
+ end
70
+ end
71
+ end
72
+
73
+ def self.included(klass)
74
+ klass.extend(ClassMethods)
75
+ end
76
+
77
+ module ClassMethods
78
+ def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
79
+ Rage::Deferred.__queue.enqueue(
80
+ Rage::Deferred::Metadata.build(self, args, kwargs),
81
+ delay:,
82
+ delay_until:
83
+ )
84
+ end
85
+
86
+ # @private
87
+ def __should_retry?(attempts)
88
+ attempts < MAX_ATTEMPTS
89
+ end
90
+
91
+ # @private
92
+ def __next_retry_in(attempts)
93
+ rand(BACKOFF_INTERVAL * 2**attempts.to_i) + 1
94
+ end
95
+ end
96
+ end
@@ -63,6 +63,7 @@ class Rage::FiberScheduler
63
63
 
64
64
  def kernel_sleep(duration = nil)
65
65
  block(nil, duration || 0)
66
+ Fiber.pause if duration.nil? || duration < 1
66
67
  end
67
68
 
68
69
  # TODO: GC works a little strange with this closure;
data/lib/rage/hooks.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ def hooks
5
+ @hooks ||= Hash.new { |h, k| h[k] = [] }
6
+ end
7
+
8
+ def push_hook(callback, hook_family)
9
+ hooks[hook_family] << callback if callback.is_a?(Proc)
10
+ end
11
+
12
+ def run_hooks_for!(hook_family, context = nil)
13
+ hooks[hook_family].each do |callback|
14
+ if context
15
+ context.instance_exec(&callback)
16
+ else
17
+ callback.call
18
+ end
19
+ end
20
+
21
+ @hooks[hook_family] = []
22
+
23
+ true
24
+ end
25
+ end
@@ -20,7 +20,7 @@ class Rage::OpenAPI::Parsers::Ext::Alba
20
20
 
21
21
  def __parse_nested(klass_str)
22
22
  __parse(klass_str).tap { |visitor|
23
- visitor.root_key = visitor.root_key_for_collection = visitor.key_transformer = nil
23
+ visitor.root_key = visitor.root_key_for_collection = visitor.root_key_proc = visitor.key_transformer = nil
24
24
  }.build_schema
25
25
  end
26
26
 
@@ -50,7 +50,7 @@ class Rage::OpenAPI::Parsers::Ext::Alba
50
50
  end
51
51
 
52
52
  class Visitor < Prism::Visitor
53
- attr_accessor :schema, :root_key, :root_key_for_collection, :key_transformer, :collection_key, :meta
53
+ attr_accessor :schema, :root_key, :root_key_for_collection, :root_key_proc, :key_transformer, :collection_key, :meta
54
54
 
55
55
  def initialize(parser, is_collection)
56
56
  @parser = parser
@@ -64,6 +64,7 @@ class Rage::OpenAPI::Parsers::Ext::Alba
64
64
  @self_name = nil
65
65
  @root_key = nil
66
66
  @root_key_for_collection = nil
67
+ @root_key_proc = nil
67
68
  @key_transformer = nil
68
69
  @collection_key = false
69
70
  @meta = {}
@@ -74,7 +75,7 @@ class Rage::OpenAPI::Parsers::Ext::Alba
74
75
 
75
76
  if node.name =~ /Resource$|Serializer$/ && node.superclass
76
77
  visitor = @parser.__parse(node.superclass.name)
77
- @root_key, @root_key_for_collection = visitor.root_key, visitor.root_key_for_collection
78
+ @root_key, @root_key_for_collection, @root_key_proc = visitor.root_key, visitor.root_key_for_collection, visitor.root_key_proc
78
79
  @key_transformer, @collection_key, @meta = visitor.key_transformer, visitor.collection_key, visitor.meta
79
80
  @schema.merge!(visitor.schema)
80
81
  end
@@ -87,6 +88,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
87
88
 
88
89
  result["properties"] = @schema if @schema.any?
89
90
 
91
+ if @root_key_proc
92
+ dynamic_root_key, dynamic_root_key_for_collection = @root_key_proc.call(@self_name)
93
+
94
+ @root_key = dynamic_root_key
95
+ @root_key_for_collection = dynamic_root_key_for_collection
96
+ end
97
+
90
98
  if @is_collection
91
99
  result = if @collection_key && @root_key_for_collection
92
100
  { "type" => "object", "properties" => { @root_key_for_collection => { "type" => "object", "additionalProperties" => result }, **@meta } }
@@ -109,6 +117,7 @@ class Rage::OpenAPI::Parsers::Ext::Alba
109
117
  def visit_call_node(node)
110
118
  case node.name
111
119
  when :root_key
120
+ @root_key_proc = nil
112
121
  context = with_context { visit(node.arguments) }
113
122
  @root_key, @root_key_for_collection = context.symbols
114
123
 
@@ -135,12 +144,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
135
144
  when :many, :has_many, :one, :has_one, :association
136
145
  is_array = node.name == :many || node.name == :has_many
137
146
  context = with_context { visit(node.arguments) }
138
- key = context.keywords["key"] || context.symbols[0]
147
+ association = context.symbols[0]
148
+ key = context.keywords["key"] || association
139
149
 
140
150
  if node.block
141
151
  with_inner_segment(key, is_array:) { visit(node.block) }
142
152
  else
143
- resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(key.to_s)}Resource")
153
+ resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(association.to_s)}Resource")
144
154
  is_valid_resource = @parser.namespace.const_get(resource) rescue false
145
155
 
146
156
  @segment[key] = if is_array
@@ -159,10 +169,14 @@ class Rage::OpenAPI::Parsers::Ext::Alba
159
169
 
160
170
  when :root_key!
161
171
  if (inflector = ::Alba.inflector)
162
- suffix = @self_name.end_with?("Resource") ? "Resource" : "Serializer"
163
- name = inflector.demodulize(@self_name).delete_suffix(suffix)
164
- @root_key = inflector.underscore(name)
165
- @root_key_for_collection = inflector.pluralize(@root_key) if @is_collection
172
+ @root_key, @root_key_for_collection = nil
173
+
174
+ @root_key_proc = ->(resource_name) do
175
+ suffix = resource_name.end_with?("Resource") ? "Resource" : "Serializer"
176
+ name = inflector.demodulize(resource_name).delete_suffix(suffix)
177
+
178
+ inflector.underscore(name).yield_self { |key| [key, inflector.pluralize(key)] }
179
+ end
166
180
  end
167
181
  end
168
182
  end
data/lib/rage/request.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require "set" # required for ruby 3.1
5
4
 
6
5
  class Rage::Request
7
6
  # @private
data/lib/rage/response.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "time"
5
+
3
6
  class Rage::Response
7
+ ETAG_HEADER = "ETag"
8
+ LAST_MODIFIED_HEADER = "Last-Modified"
9
+
4
10
  # @private
5
11
  def initialize(headers, body)
6
12
  @headers = headers
@@ -18,4 +24,37 @@ class Rage::Response
18
24
  def headers
19
25
  @headers
20
26
  end
27
+
28
+ # Returns ETag response header or +nil+ if it's empty.
29
+ #
30
+ # @return [String, nil]
31
+ def etag
32
+ headers[Rage::Response::ETAG_HEADER]
33
+ end
34
+
35
+ # Sets ETag header to the response. Additionally, it will hashify the value using +Digest::SHA1.hexdigest+. Pass +nil+ for resetting it.
36
+ # @note ETag will be always Weak since no strong validation is implemented.
37
+ # @note ArgumentError is raised if ETag value is neither +String+, nor +nil+
38
+ # @param etag [String, nil] The etag of the resource in the response.
39
+ def etag=(etag)
40
+ raise ArgumentError, "Expected `String` but `#{etag.class}` is received" unless etag.is_a?(String) || etag.nil?
41
+
42
+ headers[Rage::Response::ETAG_HEADER] = etag.nil? ? nil : %(W/"#{Digest::SHA1.hexdigest(etag)}")
43
+ end
44
+
45
+ # Returns Last-Modified response header or +nil+ if it's empty.
46
+ #
47
+ # @return [String, nil]
48
+ def last_modified
49
+ headers[Rage::Response::LAST_MODIFIED_HEADER]
50
+ end
51
+
52
+ # Sets Last-Modified header to the response by calling httpdate on the argument.
53
+ # @note ArgumentError is raised if +last_modified+ is not a +Time+ object instance
54
+ # @param last_modified [Time, nil] The last modified time of the resource in the response.
55
+ def last_modified=(last_modified)
56
+ raise ArgumentError, "Expected `Time` but `#{last_modified.class}` is received" unless last_modified.is_a?(Time) || last_modified.nil?
57
+
58
+ headers[Rage::Response::LAST_MODIFIED_HEADER] = last_modified&.httpdate
59
+ end
21
60
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  class Rage::Router::Constrainer
6
4
  attr_reader :strategies
7
5
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  module Rage::Router
6
4
  class Node
7
5
  STATIC = 0
data/lib/rage/rspec.rb CHANGED
@@ -101,7 +101,7 @@ end
101
101
  # patch MockResponse class
102
102
  class Rack::MockResponse
103
103
  def parsed_body
104
- if headers["content-type"].start_with?("application/json")
104
+ if headers["content-type"]&.start_with?("application/json")
105
105
  JSON.parse(body)
106
106
  else
107
107
  body
data/lib/rage/setup.rb CHANGED
@@ -14,4 +14,7 @@ require "rage/ext/setup"
14
14
  # Load application classes
15
15
  Rage.code_loader.setup
16
16
 
17
+ # Run after_initialize hooks
18
+ Rage.config.run_after_initialize!
19
+
17
20
  require_relative "#{Rage.root}/config/routes"
@@ -0,0 +1,2 @@
1
+ class <%= @controller_name %> < ApplicationController
2
+ 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 = "1.15.1"
4
+ VERSION = "1.17.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -22,6 +22,10 @@ module Rage
22
22
  Rage::OpenAPI
23
23
  end
24
24
 
25
+ def self.deferred
26
+ Rage::Deferred
27
+ end
28
+
25
29
  def self.routes
26
30
  Rage::Router::DSL.new(__router)
27
31
  end
@@ -107,6 +111,10 @@ module Rage
107
111
  end
108
112
  end
109
113
 
114
+ class << self
115
+ alias_method :configuration, :config
116
+ end
117
+
110
118
  module Router
111
119
  module Strategies
112
120
  end
@@ -126,6 +134,7 @@ module Rage
126
134
  autoload :Session, "rage/session"
127
135
  autoload :Cable, "rage/cable/cable"
128
136
  autoload :OpenAPI, "rage/openapi/openapi"
137
+ autoload :Deferred, "rage/deferred/deferred"
129
138
  end
130
139
 
131
140
  module RageController
data/rage.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Fast web framework compatible with Rails."
12
12
  spec.homepage = "https://github.com/rage-rb/rage"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = ">= 3.1.0"
14
+ spec.required_ruby_version = ">= 3.2.0"
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
17
  spec.metadata["source_code_uri"] = "https://github.com/rage-rb/rage"
@@ -29,7 +29,7 @@ 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", "~> 4.1"
32
+ spec.add_dependency "rage-iodine", "~> 4.3"
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
34
  spec.add_dependency "rack-test", "~> 2.1"
35
35
  spec.add_dependency "rake", ">= 12.0"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.15.1
4
+ version: 1.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-17 00:00:00.000000000 Z
10
+ date: 2025-08-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '4.1'
46
+ version: '4.3'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '4.1'
53
+ version: '4.3'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -119,19 +119,29 @@ files:
119
119
  - lib/rage/cable/cable.rb
120
120
  - lib/rage/cable/channel.rb
121
121
  - lib/rage/cable/connection.rb
122
- - lib/rage/cable/protocol/actioncable_v1_json.rb
122
+ - lib/rage/cable/protocols/actioncable_v1_json.rb
123
+ - lib/rage/cable/protocols/base.rb
124
+ - lib/rage/cable/protocols/raw_web_socket_json.rb
123
125
  - lib/rage/cable/router.rb
124
126
  - lib/rage/cli.rb
125
127
  - lib/rage/code_loader.rb
126
128
  - lib/rage/configuration.rb
127
129
  - lib/rage/controller/api.rb
128
130
  - lib/rage/cookies.rb
131
+ - lib/rage/deferred/backends/disk.rb
132
+ - lib/rage/deferred/backends/nil.rb
133
+ - lib/rage/deferred/deferred.rb
134
+ - lib/rage/deferred/metadata.rb
135
+ - lib/rage/deferred/proxy.rb
136
+ - lib/rage/deferred/queue.rb
137
+ - lib/rage/deferred/task.rb
129
138
  - lib/rage/env.rb
130
139
  - lib/rage/errors.rb
131
140
  - lib/rage/ext/active_record/connection_pool.rb
132
141
  - lib/rage/ext/setup.rb
133
142
  - lib/rage/fiber.rb
134
143
  - lib/rage/fiber_scheduler.rb
144
+ - lib/rage/hooks.rb
135
145
  - lib/rage/logger/json_formatter.rb
136
146
  - lib/rage/logger/logger.rb
137
147
  - lib/rage/logger/text_formatter.rb
@@ -186,6 +196,7 @@ files:
186
196
  - lib/rage/templates/config-initializers-.keep
187
197
  - lib/rage/templates/config-routes.rb
188
198
  - lib/rage/templates/config.ru
199
+ - lib/rage/templates/controller-template/controller.rb
189
200
  - lib/rage/templates/db-templates/app-models-application_record.rb
190
201
  - lib/rage/templates/db-templates/db-seeds.rb
191
202
  - lib/rage/templates/db-templates/mysql/config-database.yml
@@ -213,7 +224,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
213
224
  requirements:
214
225
  - - ">="
215
226
  - !ruby/object:Gem::Version
216
- version: 3.1.0
227
+ version: 3.2.0
217
228
  required_rubygems_version: !ruby/object:Gem::Requirement
218
229
  requirements:
219
230
  - - ">="