rage-rb 1.24.0 → 1.25.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: b488d2ab6a7ebcc33ae77a768f55e9277b796c39c5a7451e32a823f4e7a8a848
4
- data.tar.gz: c1e55ecf8182587ced30f1c0af834136edbd4e06d0466dfaf6685387d0ebb3d6
3
+ metadata.gz: 177c149ab02c459e231c8853a303f23da977767073611fa1996e3e9b4f3ebd7c
4
+ data.tar.gz: a28617f5ea6d85f95eb0d900582dddacf2fb83f8190f31e46f84700419ccfdc2
5
5
  SHA512:
6
- metadata.gz: 0ed0b0e62b74d3847804613ec77da801ceb2784dda9dc81895c93c6438a07377a82a5ff3ac4099bee8d4d9698ef51e2ac12233067d0672f3b5bdf97374e2fbc6
7
- data.tar.gz: 6b6b8d71317c165d7ff5d3a1942cd81aeeeb5978f3672fc1de32464f4d4e3ed8f10171345a11db2e3427aff4cd84868403922541701c5464ce4260d96690250b
6
+ metadata.gz: d1c422f7d8755b56a88203f5669d36d518a1ff69a9bc2fc9e8fbd9e0b628fc25884c44615bfbc7f577e3519e9be3c5499dc4cf7fd136065e6292e95fe1833635
7
+ data.tar.gz: 10a7692229b832f1eeb540114835c9ac49a317372e796163c3be7eb8d3d906cf89e9e3cd18c033e61834fa8dc192f1dbfc3a25f509f0a6c7496b0be8dcb4e026
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.25.0] - 2026-06-03
4
+
5
+ ### Added
6
+
7
+ - Implement `FiberScheduler#fiber_interrupt` (#283).
8
+ - [OpenAPI] Add Blueprinter parser scaffold and class detection (#287)
9
+ - [OpenAPI] Extend @response / @request tag syntax to accept serializer options (#299)
10
+ - Add `FiberScheduler#blocking_operation_wait` (#303).
11
+ - [OpenAPI] Added static parsing of basic Blueprinter fields (`identifier`, `field`, `fields`) for OpenAPI schema generation. (#289)
12
+
13
+ ### Fixed
14
+
15
+ - [API] Reject malformed or empty HTTP token authorization headers.
16
+ - [Cookies] Strip the port from `HTTP_HOST` before matching configured cookie domains.
17
+ - [Router] Strip the port from `HTTP_HOST` before matching exact host constraints.
18
+
3
19
  ## [1.24.0] - 2026-05-12
4
20
 
5
21
  ### Added
data/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
7
  ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.3%2B-%23f40000)
8
8
 
9
- **Rage** is an API-first Ruby framework with a modern, fiber-based runtime that enables transparent, non-blocking concurrency while preserving familiar developer ergonomics. It focuses on **capability and operational simplicity**, letting teams build production-grade systems in a single, coherent runtime.
9
+ **Rage** is an API-first Ruby web framework that combines the developer experience of Rails with fiber-based concurrency. You write standard synchronous Ruby code - Rage handles the concurrency, running APIs, background jobs, and WebSockets in a single process with fewer moving parts.
10
10
 
11
- Rage uses Rails compatibility as a foundation and provides backend primitives optimized for a single-runtime model: background jobs that run in-process, WebSockets without external dependencies, object-oriented domain events, and automatic API documentation.
11
+ Rage uses Rails compatibility as a foundation and provides backend primitives optimized for a single-runtime model: background jobs that run in-process, scalable WebSockets and SSE streams, object-oriented domain events, and automatic API documentation.
12
12
 
13
13
  ## Why Rage
14
14
 
@@ -24,22 +24,73 @@ In the Ruby ecosystem, these concerns typically mean more infrastructure: Redis,
24
24
 
25
25
  Rage takes a different approach: **collapse backend concerns into a single runtime** by embracing Ruby's fiber-based concurrency model. This reduces operational complexity while keeping familiar Ruby ergonomics.
26
26
 
27
+ ## Key Capabilities
28
+
29
+ - **Rails Compatibility** - Familiar controller API, routing DSL, and conventions. Migrate gradually or start fresh.
30
+ - **True Concurrency** - Fiber-based architecture handles I/O without threads, locks, or async/await syntax. Your code looks synchronous but runs concurrently.
31
+ - **Zero-dependency WebSockets** - Action Cable-compatible real-time features that work out-of-the-box, with built-in IPC for multi-process deployments.
32
+ - **Server-Sent Events** - Native SSE streaming with no external dependencies. Built for live feeds, progress updates, and LLM response streaming.
33
+ - **Auto-generated OpenAPI** - Documentation generated from your controllers using simple comment tags.
34
+ - **In-process Background Jobs** - A durable, persistent queue that runs inside your app process. No external dependencies or separate worker processes required.
35
+ - **Built-in Observability** - Track and measure application behavior with `Rage::Telemetry`. Integrate with external monitoring platforms or build custom observability solutions.
36
+
37
+ ## Installation
38
+
39
+ Install the gem:
40
+
41
+ ```
42
+ $ gem install rage-rb
43
+ ```
44
+
45
+ Create a new app:
46
+
47
+ ```
48
+ $ rage new my_app
49
+ ```
50
+
51
+ Switch to your new application and install dependencies:
52
+
53
+ ```
54
+ $ cd my_app
55
+ $ bundle install
56
+ ```
57
+
58
+ (Optional 🤖) Install agent skills:
59
+
60
+ ```
61
+ $ rage skills install
62
+ ```
63
+
64
+ Start up the server and visit http://localhost:3000.
65
+
66
+ ```
67
+ $ rage s
68
+ ```
69
+
70
+ Start coding!
71
+
72
+ ## How It Works
73
+
74
+ Rage runs each request in a separate fiber. When your code performs I/O operations - HTTP requests, database queries, file reads - the fiber automatically pauses, and Rage processes other requests. When the I/O completes, the fiber resumes exactly where it left off.
75
+
76
+ This happens automatically. You write standard Ruby code, and Rage handles the concurrency.
77
+
27
78
  ### Unified Runtime in Action
28
79
 
29
80
  Here's what single runtime looks like in practice:
30
81
 
31
82
  ```ruby
32
83
  class OrdersController < RageController::API
84
+ # Create an order record.
85
+ # @request { amount: Float, product_id: Integer }
86
+ # @response 201 Order
33
87
  def create
34
88
  order = Order.create!(order_params)
35
89
 
36
90
  # Schedule background job - runs in-process, no Redis needed
37
91
  SendOrderConfirmation.enqueue(order.id)
38
92
 
39
- # Publish domain event - subscribers execute immediately or async
40
- Rage::Events.publish(OrderPlaced.new(order: order))
41
-
42
- # Broadcast to WebSocket subscribers - no Action Cable/Redis needed
93
+ # Broadcast to WebSocket subscribers - built in, no external services needed
43
94
  Rage::Cable.broadcast("orders", { status: "created", order_id: order.id })
44
95
 
45
96
  render json: order, status: :created
@@ -55,22 +106,15 @@ class SendOrderConfirmation
55
106
  OrderMailer.confirmation(order).deliver
56
107
  end
57
108
  end
109
+ ```
58
110
 
59
- # Domain event - typed, object-oriented
60
- OrderPlaced = Data.define(:order)
111
+ This all runs in a single process. No external queues, no separate worker dynos, no additional infrastructure.
61
112
 
62
- # Event subscriber
63
- class UpdateInventory
64
- include Rage::Events::Subscriber
65
- subscribe_to OrderPlaced
113
+ ## Two Ways to Use Rage
66
114
 
67
- def call(event)
68
- Inventory.decrement(event.order.items.length)
69
- end
70
- end
71
- ```
115
+ **Standalone**: Create new services with `rage new`. You get a clean project structure, CLI tools, and everything needed to build production-ready APIs from scratch.
72
116
 
73
- This all runs in a single process. No external queues, no separate worker dynos, no Redis for pub/sub.
117
+ **Rails Integration**: Add Rage to existing Rails applications for gradual migration. Use Rage for new endpoints or high-traffic routes while keeping the rest of your Rails app unchanged. See the [Rails Integration Guide](https://rage-rb.dev/docs/rails) for details.
74
118
 
75
119
  ## Coming from Rails?
76
120
 
@@ -83,7 +127,7 @@ You write familiar synchronous Ruby code. Rage handles the concurrency.
83
127
  **What changes:**
84
128
 
85
129
  - One deployment unit instead of API servers + worker processes
86
- - No Redis required for jobs or broadcasts
130
+ - No external dependencies for jobs or broadcasts
87
131
  - Domain events as objects, not string-based notifications
88
132
  - OpenAPI specs generated automatically from your code
89
133
 
@@ -95,111 +139,9 @@ You write familiar synchronous Ruby code. Rage handles the concurrency.
95
139
 
96
140
  Think of Rage as Rails ergonomics with a runtime designed for modern API systems, where operational simplicity is a first-class concern.
97
141
 
98
- ## Core Ideas
99
-
100
- ### 1. Unified Backend Runtime
101
-
102
- Rage runs HTTP APIs, background jobs, and WebSockets in the same process by default:
103
-
104
- - No separate worker processes
105
- - No Redis required for jobs or broadcasts
106
- - One deployment unit for most applications
107
-
108
- This simplifies both local development and production setup.
109
-
110
- For high-scale scenarios, Rage supports multi-process deployments and allows Rage processes to communicate directly when needed.
111
-
112
- ### 2. API-First, Rails-Compatible
113
-
114
- Rage provides a familiar Rails-like programming model with API-focused improvements:
115
-
116
- - Controllers and routing that feel like Rails
117
- - Active Record compatibility
118
- - Incremental adoption for existing Rails applications
119
- - OpenAPI specs auto-generated from your code
120
-
121
- Rails compatibility is the foundation. Rage builds on top of that foundation with new primitives designed for high-concurrency, API-first architectures.
122
-
123
- ### 3. Built-in Asynchronous Execution
124
-
125
- Rage ships with **fiber-based, in-process background jobs**:
126
-
127
- - Zero setup - no Redis, no configuration
128
- - Jobs persist across restarts
129
- - Scheduled and executed within the same runtime using fibers for concurrency
130
-
131
- For teams that need distributed job processing, Rage works with existing solutions. But most applications can start simple and stay simple.
132
-
133
- ### 4. Structured Domain Events
134
-
135
- Rage includes a built‑in event bus designed for **object‑oriented domain events**:
136
-
137
- * Events are classes with explicit attributes, not hashes or strings
138
- * Subscribers listen to event classes or mixins
139
- * Type-safe and refactorable
140
-
141
- This encourages clear domain modeling and avoids the brittleness of string-based notification systems.
142
-
143
- ### 5. Observability by Design
144
-
145
- Observability is not an afterthought:
146
-
147
- - Structured logging by default
148
- - Dedicated observability interface for HTTP, background, and real-time features
149
- - OpenAPI specifications generated automatically from the running application
150
-
151
- API contracts stay in sync with code by default - no separate documentation pipelines.
152
-
153
- ### 6. Performance That Enables Simplicity
154
-
155
- Rage's fiber-based concurrency delivers strong performance for I/O-heavy workloads, but performance is a means to an end: operational simplicity.
156
-
157
- By handling concurrency efficiently, Rage lets you:
158
-
159
- - Run fewer servers
160
- - Deploy fewer services
161
- - Reduce infrastructure by handling workloads that traditionally required separate microservices
162
-
163
- The goal is to let teams write maintainable Ruby code while natively handling massive concurrency, eliminating the need for premature optimization or infrastructure sprawl.
164
-
165
- ## Philosophy
166
-
167
- Rage is intentionally conservative about change.
168
-
169
- The framework prioritizes:
170
-
171
- - **Stable public APIs**
172
- - **Long deprecation cycles**
173
- - **Minimal external dependencies**
174
-
175
- The goal is to let teams build systems that **age well** - without constant rewrites or growing infrastructure complexity.
176
-
177
- Our aspiration: the task "Upgrade Rage" never appears in your ticketing system. Most updates should be as simple as `bundle update`.
178
-
179
- ## What Rage Is (and Isn't)
180
-
181
- **Rage is:**
182
-
183
- - Focused on backend APIs
184
- - Opinionated about operational simplicity
185
- - Designed for long-term stability
186
- - Rails-compatible but architecturally independent
187
-
188
- **Rage is not:**
189
-
190
- - A full-stack framework - no view layer, no asset pipeline
191
- - A Rails clone - compatibility is a bridge, not the destination
192
- - Trying to do everything - deliberate scope boundaries
193
-
194
- ## Who Rage Is For
195
-
196
- Rage is a good fit if you:
142
+ ## Stability
197
143
 
198
- - Build API-only backends in Ruby
199
- - Care about operational simplicity over maximum flexibility
200
- - Want fewer moving parts in production
201
- - Prefer explicit, object-oriented design
202
- - Value long-term stability over cutting-edge features
144
+ Rage prioritizes stable public APIs, long deprecation cycles, and minimal external dependencies. Our aspiration: the task "Upgrade Rage" never appears in your ticketing system. Most updates should be as simple as `bundle update`.
203
145
 
204
146
  ## Learn More
205
147
 
@@ -281,6 +281,14 @@ class Rage::Configuration
281
281
  end
282
282
  # @!endgroup
283
283
 
284
+ # @!group Blocking Operation Pool Configuration
285
+ # Allows configuring the thread pool for offloading native calls.
286
+ # @return [Rage::Configuration::BlockingOperationPool]
287
+ def blocking_operation_pool
288
+ @blocking_operation_pool ||= BlockingOperationPool.new
289
+ end
290
+ # @!endgroup
291
+
284
292
  # @private
285
293
  def pubsub
286
294
  @pubsub ||= PubSub.new
@@ -294,7 +302,7 @@ class Rage::Configuration
294
302
  # @private
295
303
  def run_after_initialize!
296
304
  run_hooks_for!(:after_initialize, self)
297
- __finalize
305
+ __finalize(true)
298
306
  end
299
307
 
300
308
  class LogContext
@@ -1082,6 +1090,31 @@ class Rage::Configuration
1082
1090
  attr_accessor :form_actions
1083
1091
  end
1084
1092
 
1093
+ class BlockingOperationPool
1094
+ # @!attribute enabled
1095
+ # Enable a background thread pool for offloading native calls that can be executed outside the GVL, freeing
1096
+ # the main server thread to continue processing other requests. This acts as a preemption mechanism: native calls
1097
+ # won't stall requests, as the OS will context-switch between the server thread and the worker threads.
1098
+ # Defaults to `false`.
1099
+ # @return [Boolean]
1100
+ # @example Enable the thread pool
1101
+ # Rage.configure do
1102
+ # config.blocking_operation_pool.enabled = true
1103
+ # end
1104
+ #
1105
+ # @!attribute size
1106
+ # Specify the number of threads in the pool. Defaults to `1`. A single thread is sufficient in most cases
1107
+ # because the pool's goal is context switching, not parallelization.
1108
+ # @return [Integer]
1109
+ attr_accessor :enabled, :size
1110
+
1111
+ # @private
1112
+ def initialize
1113
+ @enabled = false
1114
+ @size = 1
1115
+ end
1116
+ end
1117
+
1085
1118
  # @private
1086
1119
  class PubSub
1087
1120
  attr_reader :adapter
@@ -1148,7 +1181,7 @@ class Rage::Configuration
1148
1181
  end
1149
1182
 
1150
1183
  # @private
1151
- def __finalize
1184
+ def __finalize(before_boot = false)
1152
1185
  if @logger
1153
1186
  @logger.formatter = @log_formatter if @log_formatter
1154
1187
  @logger.level = @log_level if @log_level
@@ -1170,6 +1203,15 @@ class Rage::Configuration
1170
1203
  @logger.dynamic_tags = Rage.__log_processor.dynamic_tags
1171
1204
  end
1172
1205
 
1206
+ if before_boot && @blocking_operation_pool&.enabled
1207
+ if defined?(Rage::FiberScheduler::BlockingOperationWait)
1208
+ Iodine.on_state(:pre_start) { puts "INFO: Using blocking operation pool" }
1209
+ Rage::FiberScheduler.include(Rage::FiberScheduler::BlockingOperationWait)
1210
+ else
1211
+ puts "WARNING: Blocking operation pool is not supported on Ruby #{RUBY_VERSION}"
1212
+ end
1213
+ end
1214
+
1173
1215
  if defined?(::Rack::Events) && middleware.include?(::Rack::Events)
1174
1216
  middleware.delete(Rage::BodyFinalizer)
1175
1217
  middleware.insert_before(::Rack::Events, Rage::BodyFinalizer)
@@ -558,13 +558,13 @@ class RageController::API
558
558
  def authenticate_with_http_token
559
559
  auth_header = @__env["HTTP_AUTHORIZATION"]
560
560
 
561
- payload = if auth_header&.start_with?("Bearer")
561
+ payload = if auth_header&.start_with?("Bearer ")
562
562
  auth_header[7..]
563
- elsif auth_header&.start_with?("Token")
563
+ elsif auth_header&.start_with?("Token ")
564
564
  auth_header[6..]
565
565
  end
566
566
 
567
- return unless payload
567
+ return if payload.nil? || payload.empty?
568
568
 
569
569
  token = if payload.start_with?("token=")
570
570
  payload[6..]
@@ -575,6 +575,8 @@ class RageController::API
575
575
  token.delete_prefix!('"')
576
576
  token.delete_suffix!('"')
577
577
 
578
+ return if token.empty?
579
+
578
580
  yield token
579
581
  end
580
582
 
data/lib/rage/cookies.rb CHANGED
@@ -201,7 +201,7 @@ class Rage::Cookies
201
201
  end
202
202
 
203
203
  if (domain = value[:domain])
204
- host = @env["HTTP_HOST"]
204
+ host = @env["HTTP_HOST"]&.sub(/:\d+\z/, "")
205
205
 
206
206
  processed_domain = if domain.is_a?(String)
207
207
  domain
data/lib/rage/fiber.rb CHANGED
@@ -111,23 +111,7 @@ class Fiber
111
111
  end
112
112
 
113
113
  # @private
114
- def __block_channel(force = false)
115
- @__block_channel_i ||= 0
116
- @__block_channel_i += 1 if force
117
-
118
- "block:#{object_id}:#{@__block_channel_i}"
119
- end
120
-
121
- # @private
122
- def __await_channel(force = false)
123
- @__fiber_channel_i ||= 0
124
- @__fiber_channel_i += 1 if force
125
-
126
- "await:#{object_id}:#{@__fiber_channel_i}"
127
- end
128
-
129
- # @private
130
- attr_accessor :__awaited_fileno
114
+ attr_accessor :__awaited_fileno, :__wait_generation, :__block_channel, :__await_channel
131
115
 
132
116
  # @private
133
117
  # pause a fiber and resume in the next iteration of the event loop
@@ -156,7 +140,10 @@ class Fiber
156
140
  # @note This method should only be used when multiple fibers have to be processed in parallel. There's no need to use `Fiber.await` for single IO calls.
157
141
  def self.await(fibers)
158
142
  f, fibers = Fiber.current, Array(fibers)
159
- await_channel = f.__await_channel(true)
143
+
144
+ f.__wait_generation ||= 0
145
+ gen = (f.__wait_generation += 1)
146
+ channel = f.__await_channel = "await:#{f.object_id}:#{gen}"
160
147
 
161
148
  Rage::Telemetry.tracer.span_core_fiber_await(fibers:) do
162
149
  # check which fibers are alive (i.e. have yielded) and which have errored out
@@ -179,17 +166,21 @@ class Fiber
179
166
  end
180
167
 
181
168
  # wait on async fibers; resume right away if one of the fibers errors out
182
- Iodine.subscribe(await_channel) do |_, err|
183
- if err == AWAIT_ERROR_MESSAGE
184
- f.resume
169
+ Iodine.subscribe(channel) do |_, err|
170
+ done = if err == AWAIT_ERROR_MESSAGE
171
+ true
185
172
  else
186
173
  num_wait_for -= 1
187
- f.resume if num_wait_for == 0
174
+ num_wait_for == 0
175
+ end
176
+
177
+ if done
178
+ Iodine.defer { Iodine.unsubscribe(channel) }
179
+ f.resume if f.alive? && gen == f.__wait_generation
188
180
  end
189
181
  end
190
182
 
191
183
  Fiber.defer(-1)
192
- Iodine.defer { Iodine.unsubscribe(await_channel) }
193
184
 
194
185
  # if num_wait_for is not 0 means we exited prematurely because of an error
195
186
  if num_wait_for > 0
@@ -5,14 +5,20 @@ require "resolv"
5
5
  class Rage::FiberScheduler
6
6
  MAX_READ = 65536
7
7
 
8
+ # Initialize the scheduler, storing the root fiber and an empty DNS cache.
8
9
  def initialize
9
10
  @root_fiber = Fiber.current
10
11
  @dns_cache = {}
11
12
  end
12
13
 
14
+ # Wait for I/O events on a file descriptor, yielding the fiber until ready or timeout.
13
15
  def io_wait(io, events, timeout = nil)
14
16
  f = Fiber.current
15
- ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) { |err| f.resume(err) }
17
+ gen = (f.__wait_generation += 1)
18
+
19
+ ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) do |err|
20
+ f.resume(err) if f.alive? && gen == f.__wait_generation
21
+ end
16
22
 
17
23
  err = Fiber.defer(io.fileno)
18
24
  if err == false || (err && err < 0)
@@ -22,6 +28,7 @@ class Rage::FiberScheduler
22
28
  end
23
29
  end
24
30
 
31
+ # Read data from an I/O object into a buffer, pausing the fiber between reads.
25
32
  def io_read(io, buffer, length, offset = 0)
26
33
  length_to_read = if length == 0
27
34
  buffer.size > MAX_READ ? MAX_READ : buffer.size
@@ -51,6 +58,7 @@ class Rage::FiberScheduler
51
58
  end
52
59
 
53
60
  unless ENV["RAGE_DISABLE_IO_WRITE"]
61
+ # Write data from a buffer to an I/O object.
54
62
  def io_write(io, buffer, length, offset = 0)
55
63
  bytes_to_write = length
56
64
  bytes_to_write = buffer.size if length == 0
@@ -61,6 +69,7 @@ class Rage::FiberScheduler
61
69
  end
62
70
  end
63
71
 
72
+ # Pause the current fiber for the specified duration.
64
73
  def kernel_sleep(duration = nil)
65
74
  block(nil, duration || 0)
66
75
  Fiber.pause if duration.nil? || duration < 1
@@ -80,6 +89,7 @@ class Rage::FiberScheduler
80
89
  # result
81
90
  # end
82
91
 
92
+ # Resolve a hostname to IP addresses, caching results for 60 seconds.
83
93
  def address_resolve(hostname)
84
94
  @dns_cache[hostname] ||= begin
85
95
  ::Iodine.run_after(60_000) do
@@ -90,14 +100,18 @@ class Rage::FiberScheduler
90
100
  end
91
101
  end
92
102
 
103
+ # Block the current fiber until unblocked or timeout.
93
104
  def block(_blocker, timeout = nil)
94
- f, fulfilled, channel = Fiber.current, false, Fiber.current.__block_channel(true)
105
+ f, fulfilled = Fiber.current, false
106
+
107
+ gen = (f.__wait_generation += 1)
108
+ channel = f.__block_channel = "block:#{f.object_id}:#{gen}"
95
109
 
96
110
  resume_fiber_block = proc do
97
111
  unless fulfilled
98
112
  fulfilled = true
99
113
  ::Iodine.defer { ::Iodine.unsubscribe(channel) }
100
- f.resume if f.alive?
114
+ f.resume if f.alive? && gen == f.__wait_generation
101
115
  end
102
116
  end
103
117
 
@@ -109,10 +123,38 @@ class Rage::FiberScheduler
109
123
  Fiber.yield
110
124
  end
111
125
 
126
+ # Unblock a fiber by publishing to its block channel.
112
127
  def unblock(_blocker, fiber)
113
- ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS)
128
+ ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS) if fiber.__block_channel
129
+ end
130
+
131
+ # Interrupt a fiber by incrementing its generation and raising an exception.
132
+ def fiber_interrupt(fiber, exception)
133
+ fiber.__wait_generation += 1
134
+ fiber.raise(exception)
135
+ end
136
+
137
+ if defined?(Iodine::WorkerPool)
138
+ module BlockingOperationWait
139
+ # Offload a native call to the worker pool, yielding until complete.
140
+ def blocking_operation_wait(work)
141
+ f = Fiber.current
142
+ gen = (f.__wait_generation += 1)
143
+
144
+ worker_pool.enqueue(work) do
145
+ f.resume if f.alive? && gen == f.__wait_generation
146
+ end
147
+
148
+ Fiber.yield
149
+ end
150
+
151
+ private def worker_pool
152
+ @worker_pool ||= Iodine::WorkerPool.new(Rage.config.blocking_operation_pool.size)
153
+ end
154
+ end
114
155
  end
115
156
 
157
+ # Create and schedule a new non-blocking fiber, handling request and user-spawned fibers differently.
116
158
  def fiber(&block)
117
159
  parent = Fiber.current
118
160
 
@@ -131,19 +173,22 @@ class Rage::FiberScheduler
131
173
  Fiber.current.__set_result(block.call)
132
174
  end
133
175
  # send a message for `Fiber.await` to work
134
- Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.alive?
176
+ Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.__await_channel
135
177
  rescue Exception => e
136
178
  Fiber.current.__set_err(e)
137
- Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
179
+ Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.__await_channel
138
180
  end
139
181
  end
140
182
 
183
+ fiber.__wait_generation = 0
141
184
  fiber.resume
142
185
 
143
186
  fiber
144
187
  end
145
188
 
189
+ # Clean up by closing the worker pool and Iodine scheduler.
146
190
  def close
191
+ @worker_pool&.close
147
192
  ::Iodine::Scheduler.close
148
193
  end
149
194
  end
@@ -127,6 +127,37 @@ module Rage::OpenAPI
127
127
  end
128
128
  end
129
129
 
130
+ # @private
131
+ # @return [Array<Boolean, String, Hash>] a tuple of (is_collection, serializer, args)
132
+ def self.__parse_serializer_args(str)
133
+ is_collection, inner = __try_parse_collection(str)
134
+
135
+ if is_collection
136
+ # discard is_collection since we already know this is a collection from the outer call
137
+ _, clean_inner, args = __parse_serializer_args(inner)
138
+ if args.any?
139
+ [is_collection, clean_inner, args]
140
+ else
141
+ [is_collection, clean_inner, {}]
142
+ end
143
+ elsif str =~ /^([\w:]+)\(([^)]+)\)$/
144
+ [is_collection, $1, __parse_keywords($2)]
145
+ else
146
+ [is_collection, str, {}]
147
+ end
148
+ end
149
+
150
+ # @private
151
+ def self.__parse_keywords(str)
152
+ return {} if str.nil? || str.empty?
153
+
154
+ str.split(",").each_with_object({}) do |part, hash|
155
+ option = YAML.load(part)
156
+ return nil unless option.is_a?(Hash)
157
+ hash.merge!(option.transform_keys!(&:to_sym))
158
+ end
159
+ end
160
+
130
161
  # @private
131
162
  def self.__module_parent(klass)
132
163
  klass.name =~ /::[^:]+\z/ ? Object.const_get($`) : Object
@@ -191,6 +222,7 @@ require_relative "nodes/root"
191
222
  require_relative "nodes/parent"
192
223
  require_relative "nodes/method"
193
224
  require_relative "parsers/ext/alba"
225
+ require_relative "parsers/ext/blueprinter"
194
226
  require_relative "parsers/ext/active_record"
195
227
  require_relative "parsers/yaml"
196
228
  require_relative "parsers/shared_reference"
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Parsers::Ext::Blueprinter
4
+ def initialize(namespace: Object, root: Rage::OpenAPI::Nodes::Root.new, **)
5
+ @namespace = namespace
6
+ @root = root
7
+ end
8
+
9
+ def known_definition?(str)
10
+ _, str, _ = Rage::OpenAPI.__parse_serializer_args(str)
11
+ defined?(Blueprinter::Base) && @namespace.const_get(str).ancestors.include?(Blueprinter::Base)
12
+ rescue NameError
13
+ false
14
+ end
15
+
16
+ def parse(klass_str)
17
+ visitor = __parse(klass_str)
18
+ visitor.build_schema
19
+ end
20
+
21
+ def __parse(klass_str)
22
+ is_collection, klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str)
23
+
24
+ klass = @namespace.const_get(klass_str)
25
+ source_path, _ = Object.const_source_location(klass.name)
26
+ ast = Prism.parse_file(source_path)
27
+
28
+ visitor = Visitor.new(self, is_collection)
29
+ ast.value.accept(visitor)
30
+
31
+ visitor
32
+ end
33
+
34
+ class VisitorContext
35
+ attr_accessor :symbols, :keywords, :strings
36
+
37
+ def initialize
38
+ @symbols = []
39
+ @strings = []
40
+ @keywords = {}
41
+ end
42
+ end
43
+
44
+ class Visitor < Prism::Visitor
45
+ attr_accessor :schema
46
+
47
+ def initialize(parser, is_collection)
48
+ @parser = parser
49
+ @is_collection = is_collection
50
+
51
+ @context = nil
52
+ @schema = {}
53
+ @segment = @schema
54
+ @identifier = {}
55
+ end
56
+
57
+ def build_schema
58
+ result = { "type" => "object" }
59
+
60
+ properties = {}
61
+ properties.merge!(@identifier)
62
+ properties.merge!(@schema.sort.to_h)
63
+
64
+ result["properties"] = properties if properties.any?
65
+ result = { "type" => "array", "items" => result } if @is_collection
66
+ result
67
+ end
68
+
69
+ def visit_call_node(node)
70
+ case node.name
71
+ when :identifier
72
+ context = with_context { visit(node.arguments) }
73
+ @identifier[context.symbols.first] = { "type" => "string" }
74
+
75
+ when :fields, :field
76
+ context = with_context { visit(node.arguments) }
77
+
78
+ if context.keywords["name"]
79
+ @segment[context.keywords["name"]] = { "type" => "string" }
80
+ elsif node.block
81
+ @segment[context.symbols.first] = { "type" => "string" } if context.symbols.first
82
+ @segment[context.strings.first] = { "type" => "string" } if context.strings.first
83
+ else
84
+ context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } }
85
+ context.strings.each { |string| @segment[string] = { "type" => "string" } }
86
+ end
87
+ end
88
+ end
89
+
90
+ def visit_assoc_node(node)
91
+ @context.keywords[node.key.value] = node.value.unescaped
92
+ end
93
+
94
+ def visit_symbol_node(node)
95
+ @context.symbols << node.value
96
+ end
97
+
98
+ def visit_string_node(node)
99
+ @context.strings << node.unescaped
100
+ end
101
+
102
+ private
103
+
104
+ def with_context
105
+ @context = VisitorContext.new
106
+ yield
107
+ @context
108
+ end
109
+ end
110
+ end
@@ -5,6 +5,7 @@ class Rage::OpenAPI::Parsers::Response
5
5
  Rage::OpenAPI::Parsers::SharedReference,
6
6
  Rage::OpenAPI::Parsers::Ext::ActiveRecord,
7
7
  Rage::OpenAPI::Parsers::Ext::Alba,
8
+ Rage::OpenAPI::Parsers::Ext::Blueprinter,
8
9
  Rage::OpenAPI::Parsers::YAML
9
10
  ]
10
11
 
@@ -69,7 +69,7 @@ class Rage::Router::Constrainer
69
69
  # Optimization: inline the derivation for the common built in constraints
70
70
  if !strategy.custom?
71
71
  if key == :host
72
- lines << " host: env['HTTP_HOST'.freeze],"
72
+ lines << " host: env['HTTP_HOST'.freeze]&.sub(/:\\d+\\z/, ''.freeze),"
73
73
  else
74
74
  raise ArgumentError, "unknown non-custom strategy for compiling constraint derivation function"
75
75
  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.24.0"
4
+ VERSION = "1.25.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.24.0
4
+ version: 1.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
@@ -199,6 +199,7 @@ files:
199
199
  - lib/rage/openapi/parser.rb
200
200
  - lib/rage/openapi/parsers/ext/active_record.rb
201
201
  - lib/rage/openapi/parsers/ext/alba.rb
202
+ - lib/rage/openapi/parsers/ext/blueprinter.rb
202
203
  - lib/rage/openapi/parsers/request.rb
203
204
  - lib/rage/openapi/parsers/response.rb
204
205
  - lib/rage/openapi/parsers/shared_reference.rb
@@ -293,5 +294,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
293
294
  requirements: []
294
295
  rubygems_version: 3.6.9
295
296
  specification_version: 4
296
- summary: A modern Ruby framework designed for non-blocking I/O and simpler infrastructure
297
+ summary: Fiber-based Ruby web framework combining Rails ergonomics with a unified
298
+ runtime
297
299
  test_files: []