rage-rb 1.15.1 → 1.16.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: 28c74a202068f676855b6837a45e5c32a614bad76c1af65178e3038f1f976439
4
- data.tar.gz: 53d023461db804902636c61711c49c25dde614d2b4d0ca112821ba61af9bdcf4
3
+ metadata.gz: bf740bcbb20d075fa39c87bf6b97b10daf6f2a92daf6bcf7a12a03f04dbf5621
4
+ data.tar.gz: 7fd73455dfd6cb646d52d2e6f798b558e62ffe343d3156901a867af53179e973
5
5
  SHA512:
6
- metadata.gz: eb4e1169d7a67bb0720f721279de1fd1729f5eb7f75c005b6ca4ecdb54a465bcb4ca97fa16acebc0ff256a1f4ebc7017a29cb00dfc254b6df3a3e8a1535e1e62
7
- data.tar.gz: 1133bae6fd831534848873917c61f5340bbe6ec65f446bcc07fa0b25c19c86b495afb03b029505ebe7d9569b50deb62dbca157d681f97fbeafab62a23b158d94
6
+ metadata.gz: 0d74c740eec61265329a2f7623e3fa053d1d3bb07c3da7162b7551beded19bb3f6dc855cf5f92ec8acc501446ab72ee8d147904794f8892d967ceba939b3a10e
7
+ data.tar.gz: 169cab9535c8dc1ec9d99614dc8201e3cc2215126669965809618ae2effdd9384a6a4ab121913ce149d299111fce7f39eef8a00a2ba362abee755514f5b5c099
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.16.0] - 2025-05-20
4
+
5
+ ### Added
6
+
7
+ - [Cable] Add the `RawJSON` protocol (#150).
8
+ - Add the `after_initialize` hook by [@serhii-sadovskyi](https://github.com/serhii-sadovskyi) (#149).
9
+
10
+ ### Fixed
11
+
12
+ - Correctly parse plaintext responses in RSpec (#151).
13
+ - [OpenAPI] Correctly handle `root_key!` (#148).
14
+ - [OpenAPI] Correctly handle the `key` option in associations (#147).
15
+
3
16
  ## [1.15.1] - 2025-04-17
4
17
 
5
18
  ### Fixed
data/OVERVIEW.md CHANGED
@@ -1,10 +1,18 @@
1
- ### Boot sequence and request lifecycle
1
+ ### Table of Contents
2
+
3
+ [API Workflow](#api-workflow)<br>
4
+ [Executing Controller Actions](#executing-controller-actions)<br>
5
+ [Cable Workflow](#cable-workflow)<br>
6
+ [OpenAPI Workflow](#openapi-workflow)<br>
7
+ [Design Principles](#design-principles)<br>
8
+
9
+ ### API Workflow
2
10
 
3
11
  The following diagram describes some of Rage's internal components and the way they interact with each other:
4
12
 
5
13
  ![overview](https://github.com/rage-rb/rage/assets/2270393/0d45bbe3-622c-4b17-b8d8-552c567fecb3)
6
14
 
7
- ### Executing controller actions
15
+ ### Executing Controller Actions
8
16
 
9
17
  When `Rage::Router::DSL` parses the `config/routes.rb` file and calls the `Rage::Router::Backend` class, it registers actions and stores handler procs.
10
18
 
@@ -51,3 +59,25 @@ After that, Rage will create and store a handler proc that will look exactly lik
51
59
  ```
52
60
 
53
61
  All of this happens at boot time. Once the request comes in at runtime, Rage will only need to retrieve the handler proc defined earlier and call it.
62
+
63
+ ### Cable Workflow
64
+
65
+ The following diagram describes the components of a `Rage::Cable` application:
66
+
67
+ ![cable](https://github.com/user-attachments/assets/86db2091-f93a-44f8-9512-c4701770d09e)
68
+
69
+ ### OpenAPI Workflow
70
+
71
+ The following diagram describes the flow of `Rage::OpenAPI`:
72
+
73
+ <img width="800" src="https://github.com/user-attachments/assets/b4a87b1e-9a0f-4432-a3e9-0106ff546f3f" />
74
+
75
+ ### Design Principles
76
+
77
+ * **Lean Happy Path:** we try to execute as many operations as possible during server initialization to minimize workload during request processing. Additionally, new features should be designed to avoid impacting the framework performance for users who do not utilize those features.
78
+
79
+ * **Performance Over Code Style:** we recognize the distinct requirements of framework and client code. Testability, readability, and maintainability are crucial for client code used in application development. Conversely, library code addresses different tasks and should be designed with different objectives. In library code, performance and abstraction to enable future modifications while maintaining backward compatibility take precedence over typical client code concerns, though testability and readability remain important.
80
+
81
+ * **Rails Compatibility:** Rails compatibility is a key objective to ensure a seamless transition for developers. While it may not be feasible to replicate every method implemented in Rails, the framework should function in a familiar and expected manner.
82
+
83
+ * **Single-Threaded Fiber-Based Approach:** each request is processed in a separate, isolated execution context (Fiber), pausing whenever it encounters blocking I/O. This single-threaded approach eliminates thread synchronization overhead, leading to enhanced performance and simplified code.
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/rage-rb.svg)](https://badge.fury.io/rb/rage-rb)
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
- ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.1%2B-%23f40000)
7
+ ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.2%2B-%23f40000)
8
8
 
9
9
  Rage is a high-performance framework compatible with Rails, featuring [WebSocket](https://github.com/rage-rb/rage/wiki/WebSockets-guide) support and automatic generation of [OpenAPI](https://github.com/rage-rb/rage/wiki/OpenAPI-Guide) documentation for your APIs. The framework is built on top of [Iodine](https://github.com/rage-rb/iodine) and is based on the following design principles:
10
10
 
data/lib/rage/all.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require_relative "../rage-rb"
2
2
 
3
3
  require_relative "version"
4
+ require_relative "hooks"
4
5
  require_relative "application"
5
6
  require_relative "fiber"
6
7
  require_relative "fiber_scheduler"
@@ -107,7 +107,7 @@ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
107
107
  if data
108
108
  data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, broadcaster_uuid, _, message_uuid)|
109
109
  if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid
110
- Rage.config.cable.protocol.broadcast(stream_name, JSON.parse(serialized_data))
110
+ Rage.cable.__protocol.broadcast(stream_name, JSON.parse(serialized_data))
111
111
  end
112
112
 
113
113
  last_id = id
@@ -36,6 +36,7 @@ module Rage::Cable
36
36
  @__protocol ||= Rage.config.cable.protocol.tap { |protocol| protocol.init(__router) }
37
37
  end
38
38
 
39
+ # @private
39
40
  def self.__adapter
40
41
  @__adapter ||= Rage.config.cable.adapter
41
42
  end
@@ -136,11 +137,15 @@ module Rage::Cable
136
137
  autoload :Redis, "rage/cable/adapters/redis"
137
138
  end
138
139
 
139
- module Protocol
140
+ module Protocols
140
141
  end
142
+
143
+ Protocol = Protocols
141
144
  end
142
145
 
143
- require_relative "protocol/actioncable_v1_json"
146
+ require_relative "protocols/base"
147
+ require_relative "protocols/actioncable_v1_json"
148
+ require_relative "protocols/raw_web_socket_json"
144
149
  require_relative "channel"
145
150
  require_relative "connection"
146
151
  require_relative "router"
@@ -1,5 +1,3 @@
1
- require "set"
2
-
3
1
  class Rage::Cable::Channel
4
2
  # @private
5
3
  INTERNAL_ACTIONS = [:subscribed, :unsubscribed]
@@ -18,6 +16,8 @@ class Rage::Cable::Channel
18
16
  public_instance_methods(true) - Rage::Cable::Channel.public_instance_methods(true)
19
17
  ).reject { |m| m.start_with?("__rage_tmp") || m.start_with?("__run") }
20
18
 
19
+ actions.reject! { |m| m != :receive } unless Rage.cable.__protocol.supports_rpc?
20
+
21
21
  @__prepared_actions = (INTERNAL_ACTIONS + actions).each_with_object({}) do |action_name, memo|
22
22
  memo[action_name] = __register_action_proc(action_name)
23
23
  end
@@ -406,7 +406,7 @@ class Rage::Cable::Channel
406
406
  #
407
407
  # @param stream [String] the name of the stream
408
408
  def stream_from(stream)
409
- Rage.config.cable.protocol.subscribe(@__connection, stream, @__params)
409
+ Rage.cable.__protocol.subscribe(@__connection, stream, @__params)
410
410
  end
411
411
 
412
412
  # Broadcast data to all the clients subscribed to a stream.
@@ -429,7 +429,7 @@ class Rage::Cable::Channel
429
429
  # transmit({ message: "Hello!" })
430
430
  # end
431
431
  def transmit(data)
432
- message = Rage.config.cable.protocol.serialize(@__params, data)
432
+ message = Rage.cable.__protocol.serialize(@__params, data)
433
433
 
434
434
  if @__is_subscribing
435
435
  # we expect a confirmation message to be sent as a result of a successful subscribe call;
@@ -1,29 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zlib"
4
- require "set"
5
-
6
3
  ##
7
- # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
8
- # The class that defines a protocol should respond to the following methods:
4
+ # This is an implementation of the `Action Cable` protocol. Clients are expected to use
5
+ # {https://www.npmjs.com/package/@rails/actioncable @rails/actioncable} to connect to the server.
6
+ #
7
+ # @see Rage::Cable::Protocols::Base
8
+ #
9
+ # @example Server side
10
+ # class TodoItemsChannel
11
+ # def subscribed
12
+ # stream_from "todo-items"
13
+ # end
9
14
  #
10
- # * `protocol_definition`
11
- # * `init`
12
- # * `on_open`
13
- # * `on_message`
14
- # * `serialize`
15
- # * `subscribe`
16
- # * `broadcast`
15
+ # def add_item(data)
16
+ # puts "Adding Todo item: #{data}"
17
+ # end
17
18
  #
18
- # The two optional methods are:
19
+ # def remove_item(data)
20
+ # puts "Removing Todo item: #{data}"
21
+ # end
22
+ # end
19
23
  #
20
- # * `on_shutdown`
21
- # * `on_close`
24
+ # @example Client side
25
+ # import { createConsumer } from '@rails/actioncable'
22
26
  #
23
- # It is likely that all logic around `@subscription_identifiers` has nothing to do with the protocol itself and
24
- # should be extracted into another class. We'll refactor this once we start working on a new protocol.
27
+ # const cable = createConsumer('ws://localhost:3000/cable')
25
28
  #
26
- class Rage::Cable::Protocol::ActioncableV1Json
29
+ # const channel = cable.subscriptions.create('TodoItemsChannel', {
30
+ # connected: () => console.log('connected')
31
+ # })
32
+ #
33
+ # channel.perform('add_item', { item: 'New Item' })
34
+ # channel.perform('remove_item', { item_id: 123 })
35
+ #
36
+ class Rage::Cable::Protocols::ActioncableV1Json < Rage::Cable::Protocols::Base
27
37
  module TYPE
28
38
  WELCOME = "welcome"
29
39
  DISCONNECT = "disconnect"
@@ -59,7 +69,7 @@ class Rage::Cable::Protocol::ActioncableV1Json
59
69
  #
60
70
  # @param router [Rage::Cable::Router]
61
71
  def self.init(router)
62
- @router = router
72
+ super
63
73
 
64
74
  Iodine.on_state(:on_start) do
65
75
  ping_counter = Time.now.to_i
@@ -69,24 +79,6 @@ class Rage::Cable::Protocol::ActioncableV1Json
69
79
  Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json, Iodine::PubSub::PROCESS)
70
80
  end
71
81
  end
72
-
73
- # Hash<String(stream name) => Set<Hash>(subscription params)>
74
- @subscription_identifiers = Hash.new { |hash, key| hash[key] = Set.new }
75
-
76
- Iodine.on_state(:pre_start) do
77
- # this is a fallback to synchronize subscription identifiers across different worker processes;
78
- # we expect connections to be distributed among all workers, so this code will almost never be called;
79
- # we also synchronize subscriptions with the master process so that the forks that are spun up instead
80
- # of the crashed ones also had access to the identifiers;
81
- Iodine.subscribe("cable:synchronize") do |_, subscription_msg|
82
- stream_name, params = Rage::ParamsParser.json_parse(subscription_msg)
83
- @subscription_identifiers[stream_name] << params
84
- end
85
- end
86
-
87
- Iodine.on_state(:on_finish) do
88
- Iodine.unsubscribe("cable:synchronize")
89
- end
90
82
  end
91
83
 
92
84
  # The method is called any time a new WebSocket connection is established.
@@ -148,7 +140,7 @@ class Rage::Cable::Protocol::ActioncableV1Json
148
140
  end
149
141
  end
150
142
 
151
- # The method should process client disconnections and call {Rage::Cable::Router#process_message}.
143
+ # The method should process client disconnections and call {Rage::Cable::Router#process_disconnection}.
152
144
  #
153
145
  # @note This method is optional.
154
146
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
@@ -164,28 +156,4 @@ class Rage::Cable::Protocol::ActioncableV1Json
164
156
  def self.serialize(params, data)
165
157
  { identifier: params.to_json, message: data }.to_json
166
158
  end
167
-
168
- # Subscribe to a stream.
169
- #
170
- # @param connection [Rage::Cable::WebSocketConnection] the connection object
171
- # @param name [String] the stream name
172
- # @param params [Hash] parameters associated with the client
173
- def self.subscribe(connection, name, params)
174
- connection.subscribe("cable:#{name}:#{Zlib.crc32(params.to_s)}")
175
-
176
- unless @subscription_identifiers[name].include?(params)
177
- @subscription_identifiers[name] << params
178
- ::Iodine.publish("cable:synchronize", [name, params].to_json)
179
- end
180
- end
181
-
182
- # Broadcast data to all clients connected to a stream.
183
- #
184
- # @param name [String] the stream name
185
- # @param data [Object] the data to send
186
- def self.broadcast(name, data)
187
- @subscription_identifiers[name].each do |params|
188
- ::Iodine.publish("cable:#{name}:#{Zlib.crc32(params.to_s)}", serialize(params, data))
189
- end
190
- end
191
159
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ ##
6
+ # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
7
+ # A protocol class should inherit from {Rage::Cable::Protocols::Base} and implement the following methods:
8
+ #
9
+ # * `on_open`
10
+ # * `on_message`
11
+ # * `serialize`
12
+ #
13
+ # The optional methods are:
14
+ #
15
+ # * `protocol_definition`
16
+ # * `on_shutdown`
17
+ # * `on_close`
18
+ #
19
+ class Rage::Cable::Protocols::Base
20
+ # @private
21
+ HANDSHAKE_HEADERS = {}
22
+
23
+ class << self
24
+ # @param router [Rage::Cable::Router]
25
+ def init(router)
26
+ @router = router
27
+
28
+ # Hash<String(stream name) => Set<Hash>(subscription params)>
29
+ @subscription_identifiers = Hash.new { |hash, key| hash[key] = Set.new }
30
+
31
+ Iodine.on_state(:pre_start) do
32
+ # this is a fallback to synchronize subscription identifiers across different worker processes;
33
+ # we expect connections to be distributed among all workers, so this code will almost never be called;
34
+ # we also synchronize subscriptions with the master process so that the forks that are spun up instead
35
+ # of the crashed ones also had access to the identifiers;
36
+ Iodine.subscribe("cable:synchronize") do |_, subscription_msg|
37
+ stream_name, params = Rage::ParamsParser.json_parse(subscription_msg)
38
+ @subscription_identifiers[stream_name] << params
39
+ end
40
+ end
41
+
42
+ Iodine.on_state(:on_finish) do
43
+ Iodine.unsubscribe("cable:synchronize")
44
+ end
45
+ end
46
+
47
+ def protocol_definition
48
+ HANDSHAKE_HEADERS
49
+ end
50
+
51
+ # Subscribe to a stream.
52
+ #
53
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
54
+ # @param name [String] the stream name
55
+ # @param params [Hash] parameters associated with the client
56
+ def subscribe(connection, name, params)
57
+ connection.subscribe("cable:#{name}:#{stream_id(params)}")
58
+
59
+ unless @subscription_identifiers[name].include?(params)
60
+ @subscription_identifiers[name] << params
61
+ ::Iodine.publish("cable:synchronize", [name, params].to_json)
62
+ end
63
+ end
64
+
65
+ # Broadcast data to all clients connected to a stream.
66
+ #
67
+ # @param name [String] the stream name
68
+ # @param data [Object] the data to send
69
+ def broadcast(name, data)
70
+ @subscription_identifiers[name].each do |params|
71
+ ::Iodine.publish("cable:#{name}:#{stream_id(params)}", serialize(params, data))
72
+ end
73
+ end
74
+
75
+ # Whether the protocol allows remote procedure calls.
76
+ #
77
+ # @return [Boolean]
78
+ def supports_rpc?
79
+ true
80
+ end
81
+
82
+ private
83
+
84
+ def stream_id(params)
85
+ Digest::MD5.hexdigest(params.to_s)
86
+ end
87
+ end # class << self
88
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The `RawWebSocketJson` protocol allows a direct connection to a {Rage::Cable} application using the native
5
+ # `WebSocket` object. With this protocol, each WebSocket connection directly corresponds to a single
6
+ # channel subscription. As a result, clients are automatically subscribed to a channel as soon as
7
+ # they establish a connection.
8
+ #
9
+ # Heartbeats are also supported - the server will respond with `pong` to every `ping` message. Additionally,
10
+ # all ping messages are buffered and processed once a second, which means it can take up to a second for
11
+ # the server to respond to a `ping`.
12
+ #
13
+ # @see Rage::Cable::Protocols::Base
14
+ #
15
+ # @example Server side
16
+ # class TodoItemsChannel
17
+ # def subscribed
18
+ # stream_from "todo-items-#{params[:user_id]}"
19
+ # end
20
+ #
21
+ # def receive(data)
22
+ # puts "New Todo item: #{data}"
23
+ # end
24
+ # end
25
+ #
26
+ # @example Client side
27
+ # socket = new WebSocket("ws://localhost:3000/cable/todo_items?user_id=123")
28
+ # socket.send(JSON.stringify({ item: "New Item" }))
29
+ #
30
+ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
31
+ # identifiers are used to distinguish between different channels that share a single connection;
32
+ # since the raw protocol uses a single connection for each channel, identifiers are not necessary
33
+ IDENTIFIER = ""
34
+ private_constant :IDENTIFIER
35
+
36
+ module MESSAGES
37
+ UNAUTHORIZED = { err: "unauthorized" }.to_json
38
+ REJECTED = { err: "subscription rejected" }.to_json
39
+ INVALID = { err: "invalid channel name" }.to_json
40
+ UNKNOWN = { err: "unknown action" }.to_json
41
+ end
42
+ private_constant :MESSAGES
43
+
44
+ DEFAULT_PARAMS = {}.freeze
45
+ private_constant :DEFAULT_PARAMS
46
+
47
+ def self.init(router)
48
+ super
49
+
50
+ @ping_connections = Set.new
51
+
52
+ Iodine.on_state(:on_start) do
53
+ Iodine.run_every(1_000) do
54
+ @ping_connections.each_slice(500) do |slice|
55
+ Iodine.defer { slice.each { |connection| connection.write("pong") } }
56
+ end
57
+
58
+ @ping_connections.clear
59
+ end
60
+ end
61
+ end
62
+
63
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
64
+ def self.on_open(connection)
65
+ accepted = @router.process_connection(connection)
66
+
67
+ unless accepted
68
+ connection.write(MESSAGES::UNAUTHORIZED)
69
+ connection.close
70
+ return
71
+ end
72
+
73
+ channel_id = connection.env["PATH_INFO"].split("/")[-1]
74
+
75
+ channel_name = if channel_id.end_with?("Channel")
76
+ channel_id
77
+ else
78
+ if channel_id.include?("_")
79
+ tmp = ""
80
+ channel_id.split("_") { |segment| tmp += segment.capitalize! || segment }
81
+ channel_id = tmp
82
+ else
83
+ channel_id.capitalize!
84
+ end
85
+
86
+ "#{channel_id}Channel"
87
+ end
88
+
89
+ query_string = connection.env["QUERY_STRING"]
90
+ params = query_string == "" ? DEFAULT_PARAMS : Iodine::Rack::Utils.parse_nested_query(query_string)
91
+
92
+ status = @router.process_subscription(connection, IDENTIFIER, channel_name, params)
93
+
94
+ if status == :rejected
95
+ connection.write(MESSAGES::REJECTED)
96
+ connection.close
97
+ elsif status == :invalid
98
+ connection.write(MESSAGES::INVALID)
99
+ connection.close
100
+ end
101
+ end
102
+
103
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
104
+ # @param raw_data [String] the message body
105
+ def self.on_message(connection, raw_data)
106
+ if raw_data == "ping"
107
+ @ping_connections << connection
108
+ return
109
+ end
110
+
111
+ data = JSON.parse(raw_data)
112
+
113
+ message_status = @router.process_message(connection, IDENTIFIER, :receive, data)
114
+ unless message_status == :processed
115
+ connection.write(MESSAGES::UNKNOWN)
116
+ end
117
+ end
118
+
119
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
120
+ def self.on_close(connection)
121
+ @router.process_disconnection(connection)
122
+ end
123
+
124
+ # @param data [Object] the object to serialize
125
+ def self.serialize(_, data)
126
+ data.to_json
127
+ end
128
+
129
+ # @return [Boolean]
130
+ def self.supports_rpc?
131
+ false
132
+ end
133
+
134
+ # @private
135
+ # The base implementation groups connection subscriptions by `params`;
136
+ # however, with `RawWebSocketJson`, params are not part of the payload (see {serialize})
137
+ # and we can disable grouping in exchange for better performance
138
+ #
139
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
140
+ # @param name [String] the stream name
141
+ def self.subscribe(connection, name, _)
142
+ super(connection, name, "")
143
+ end
144
+ end
@@ -35,6 +35,15 @@ require "erb"
35
35
  #
36
36
  # > Defines one or several old secrets that need to be rotated. Can accept a single key or an array of keys. Rage will fall back to the `FALLBACK_SECRET_KEY_BASE` environment variable if this is not set.
37
37
  #
38
+ # • _config.after_initialize_
39
+ #
40
+ # > Schedule a block of code to run after Rage has finished loading the application code. Use this to reference application-level constants during the initialization process.
41
+ # > ```
42
+ # Rage.config.after_initialize do
43
+ # SUPER_USER = User.find_by!(super: true)
44
+ # end
45
+ # > ```
46
+ #
38
47
  # # Middleware Configuration
39
48
  #
40
49
  # • _config.middleware.use_
@@ -115,7 +124,7 @@ require "erb"
115
124
  #
116
125
  # • _config.cable.protocol_
117
126
  #
118
- # > Specifies the protocol the server will use. The only value currently supported is `Rage::Cable::Protocol::ActioncableV1Json`. The client application will need to use [@rails/actioncable](https://www.npmjs.com/package/@rails/actioncable) to talk to the server.
127
+ # > Specifies the protocol the server will use. Supported values include {Rage::Cable::Protocols::ActioncableV1Json :actioncable_v1_json} and {Rage::Cable::Protocols::RawWebSocketJson :raw_websocket_json}. Defaults to {Rage::Cable::Protocols::ActioncableV1Json :actioncable_v1_json}.
119
128
  #
120
129
  # • _config.cable.allowed_request_origins_
121
130
  #
@@ -153,6 +162,8 @@ require "erb"
153
162
  # > Instructs Rage to not reuse Active Record connections between different fibers.
154
163
  #
155
164
  class Rage::Configuration
165
+ include Hooks
166
+
156
167
  attr_accessor :logger
157
168
  attr_reader :log_formatter, :log_level
158
169
  attr_writer :secret_key_base, :fallback_secret_key_base
@@ -201,6 +212,14 @@ class Rage::Configuration
201
212
  @internal ||= Internal.new
202
213
  end
203
214
 
215
+ def after_initialize(&block)
216
+ push_hook(block, :after_initialize)
217
+ end
218
+
219
+ def run_after_initialize!
220
+ run_hooks_for!(:after_initialize, self)
221
+ end
222
+
204
223
  class Server
205
224
  attr_accessor :port, :workers_count, :timeout, :max_clients
206
225
  attr_reader :threads_count
@@ -257,15 +276,29 @@ class Rage::Configuration
257
276
  end
258
277
 
259
278
  class Cable
260
- attr_accessor :protocol, :allowed_request_origins, :disable_request_forgery_protection
279
+ attr_accessor :allowed_request_origins, :disable_request_forgery_protection
280
+ attr_reader :protocol
261
281
 
262
282
  def initialize
263
- @protocol = Rage::Cable::Protocol::ActioncableV1Json
283
+ @protocol = Rage::Cable::Protocols::ActioncableV1Json
264
284
  @allowed_request_origins = if Rage.env.development? || Rage.env.test?
265
285
  /localhost/
266
286
  end
267
287
  end
268
288
 
289
+ def protocol=(protocol)
290
+ @protocol = case protocol
291
+ when Class
292
+ protocol
293
+ when :actioncable_v1_json
294
+ Rage::Cable::Protocols::ActioncableV1Json
295
+ when :raw_websocket_json
296
+ Rage::Cable::Protocols::RawWebSocketJson
297
+ else
298
+ raise ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`."
299
+ end
300
+ end
301
+
269
302
  # @private
270
303
  def middlewares
271
304
  @middlewares ||= begin
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
@@ -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"
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.16.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -107,6 +107,10 @@ module Rage
107
107
  end
108
108
  end
109
109
 
110
+ class << self
111
+ alias_method :configuration, :config
112
+ end
113
+
110
114
  module Router
111
115
  module Strategies
112
116
  end
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"
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.16.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-05-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor
@@ -119,7 +119,9 @@ 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
@@ -132,6 +134,7 @@ files:
132
134
  - lib/rage/ext/setup.rb
133
135
  - lib/rage/fiber.rb
134
136
  - lib/rage/fiber_scheduler.rb
137
+ - lib/rage/hooks.rb
135
138
  - lib/rage/logger/json_formatter.rb
136
139
  - lib/rage/logger/logger.rb
137
140
  - lib/rage/logger/text_formatter.rb
@@ -213,7 +216,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
213
216
  requirements:
214
217
  - - ">="
215
218
  - !ruby/object:Gem::Version
216
- version: 3.1.0
219
+ version: 3.2.0
217
220
  required_rubygems_version: !ruby/object:Gem::Requirement
218
221
  requirements:
219
222
  - - ">="