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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +65 -123
- data/lib/rage/configuration.rb +44 -2
- data/lib/rage/controller/api.rb +5 -3
- data/lib/rage/cookies.rb +1 -1
- data/lib/rage/fiber.rb +14 -23
- data/lib/rage/fiber_scheduler.rb +51 -6
- data/lib/rage/openapi/openapi.rb +32 -0
- data/lib/rage/openapi/parsers/ext/blueprinter.rb +110 -0
- data/lib/rage/openapi/parsers/response.rb +1 -0
- data/lib/rage/router/constrainer.rb +1 -1
- data/lib/rage/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 177c149ab02c459e231c8853a303f23da977767073611fa1996e3e9b4f3ebd7c
|
|
4
|
+
data.tar.gz: a28617f5ea6d85f95eb0d900582dddacf2fb83f8190f31e46f84700419ccfdc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**Rage** is an API-first Ruby framework
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
class UpdateInventory
|
|
64
|
-
include Rage::Events::Subscriber
|
|
65
|
-
subscribe_to OrderPlaced
|
|
113
|
+
## Two Ways to Use Rage
|
|
66
114
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
data/lib/rage/configuration.rb
CHANGED
|
@@ -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)
|
data/lib/rage/controller/api.rb
CHANGED
|
@@ -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
|
|
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
data/lib/rage/fiber.rb
CHANGED
|
@@ -111,23 +111,7 @@ class Fiber
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
# @private
|
|
114
|
-
|
|
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
|
-
|
|
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(
|
|
183
|
-
if err == AWAIT_ERROR_MESSAGE
|
|
184
|
-
|
|
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
|
-
|
|
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
|
data/lib/rage/fiber_scheduler.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
data/lib/rage/openapi/openapi.rb
CHANGED
|
@@ -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
|
|
@@ -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
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.
|
|
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:
|
|
297
|
+
summary: Fiber-based Ruby web framework combining Rails ergonomics with a unified
|
|
298
|
+
runtime
|
|
297
299
|
test_files: []
|