rage-rb 1.21.2 → 1.22.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 +9 -1
- data/lib/rage/cable/cable.rb +8 -1
- data/lib/rage/cable/channel.rb +34 -0
- data/lib/rage/cable/protocols/base.rb +9 -0
- data/lib/rage/cable/protocols/raw_web_socket_json.rb +5 -0
- data/lib/rage/configuration.rb +1 -1
- data/lib/rage/controller/api.rb +29 -7
- data/lib/rage/cookies.rb +148 -23
- data/lib/rage/fiber_scheduler.rb +1 -1
- data/lib/rage/openapi/converter.rb +6 -2
- data/lib/rage/openapi/parser.rb +57 -12
- data/lib/rage/sse/application.rb +58 -0
- data/lib/rage/sse/connection_proxy.rb +60 -0
- data/lib/rage/sse/message.rb +25 -0
- data/lib/rage/sse/sse.rb +31 -0
- data/lib/rage/telemetry/spans/dispatch_fiber.rb +1 -1
- data/lib/rage/telemetry/spans/process_sse_stream.rb +52 -0
- data/lib/rage/telemetry/telemetry.rb +5 -3
- data/lib/rage/telemetry/tracer.rb +5 -7
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +7 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9116b996c0dd32f117d192f6fbb5df697a721a11a833a6f0e3aba30d683c691
|
|
4
|
+
data.tar.gz: a0ea041ff5ef2c7059e12fd32ab095f36d60bb6281c0679c66c33804e5401227
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3962b6460dfc475f5d8709bd8847f994745b40b9cb486f720c967bc565e812d787613616eaa61fcb66c38153d49d5390983e5a2a10039b73abcfb414db60fadc
|
|
7
|
+
data.tar.gz: e844472bcfbcee55e78d4e2daf63f9e344ad977d7cad413b44c0adb3ffd0483e8e95bc3de24cce874d7d4f23c7dd8a820e7ac87cfcd8dda623264f4a4129304c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.22.0] - 2026-03-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- [Cable] Add support for `stop_stream_from` and `stop_stream_for` by [@Digvijay](https://github.com/Digvijay-x1) (#217).
|
|
8
|
+
- Add support for signed cookies by [@rfronczyk](https://github.com/rfronczyk) (#226).
|
|
9
|
+
- [OpenAPI] Add support for shared components in `@auth` tags by [@Piyush-Goenka](https://github.com/Piyush-Goenka) (#221).
|
|
10
|
+
- Add support for server-sent events (#220).
|
|
11
|
+
|
|
3
12
|
## [1.21.2] - 2026-03-11
|
|
4
13
|
|
|
5
14
|
- Fix duplicate Rake tasks (#238).
|
|
@@ -418,4 +427,3 @@
|
|
|
418
427
|
- support the `root`, `get`, `post`, `patch`, `put`, `delete` methods;
|
|
419
428
|
- support the `scope` method with the `path` and `module` options;
|
|
420
429
|
- support `host` constraint;
|
|
421
|
-
|
data/lib/rage/cable/cable.rb
CHANGED
|
@@ -44,7 +44,8 @@ module Rage::Cable
|
|
|
44
44
|
|
|
45
45
|
application = ->(env) do
|
|
46
46
|
Rage::Telemetry.tracer.span_cable_websocket_handshake(env:) do
|
|
47
|
-
if env["
|
|
47
|
+
if env["HTTP_UPGRADE"] == "websocket" || env["HTTP_UPGRADE"]&.downcase == "websocket"
|
|
48
|
+
env["rack.upgrade?"] = :websocket
|
|
48
49
|
env["rack.upgrade"] = handler
|
|
49
50
|
accept_response
|
|
50
51
|
else
|
|
@@ -162,6 +163,12 @@ module Rage::Cable
|
|
|
162
163
|
# def subscribe(name)
|
|
163
164
|
# end
|
|
164
165
|
#
|
|
166
|
+
# # Unsubscribe from a channel.
|
|
167
|
+
# #
|
|
168
|
+
# # @param name [String] the channel name
|
|
169
|
+
# def unsubscribe(name)
|
|
170
|
+
# end
|
|
171
|
+
#
|
|
165
172
|
# # Close the connection.
|
|
166
173
|
# def close
|
|
167
174
|
# end
|
data/lib/rage/cable/channel.rb
CHANGED
|
@@ -466,6 +466,40 @@ class Rage::Cable::Channel
|
|
|
466
466
|
stream_from(self.class.__stream_name_for(streamable))
|
|
467
467
|
end
|
|
468
468
|
|
|
469
|
+
# Unsubscribe from a global stream.
|
|
470
|
+
#
|
|
471
|
+
# @param stream [String] the name of the stream
|
|
472
|
+
# @raise [ArgumentError] if the stream name is not a String
|
|
473
|
+
# @example Unsubscribe from a stream and subscribe to a new one
|
|
474
|
+
# class ChatChannel < Rage::Cable::Channel
|
|
475
|
+
# def subscribed
|
|
476
|
+
# stream_from "chat_#{params[:room]}"
|
|
477
|
+
# end
|
|
478
|
+
#
|
|
479
|
+
# def switch_room(data)
|
|
480
|
+
# stop_stream_from "chat_#{params[:room]}"
|
|
481
|
+
# stream_from "chat_#{data['new_room']}"
|
|
482
|
+
# end
|
|
483
|
+
# end
|
|
484
|
+
def stop_stream_from(stream)
|
|
485
|
+
raise ArgumentError, "Stream name must be a String" unless stream.is_a?(String)
|
|
486
|
+
Rage.cable.__protocol.unsubscribe(@__connection, stream, @__params)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Unsubscribe from a local stream. The counterpart to {stream_for}.
|
|
490
|
+
#
|
|
491
|
+
# @param streamable [#id, String, Symbol, Numeric, Array] an object that will be used to generate the stream name
|
|
492
|
+
# @raise [ArgumentError] if the streamable object does not satisfy the type requirements
|
|
493
|
+
# @example Unsubscribe from a model stream
|
|
494
|
+
# class NotificationsChannel < Rage::Cable::Channel
|
|
495
|
+
# def unfollow(data)
|
|
496
|
+
# stop_stream_for User.find(data['user_id'])
|
|
497
|
+
# end
|
|
498
|
+
# end
|
|
499
|
+
def stop_stream_for(streamable)
|
|
500
|
+
stop_stream_from(self.class.__stream_name_for(streamable))
|
|
501
|
+
end
|
|
502
|
+
|
|
469
503
|
# Broadcast data to all the clients subscribed to a stream.
|
|
470
504
|
#
|
|
471
505
|
# @param stream [String] the name of the stream
|
|
@@ -62,6 +62,15 @@ class Rage::Cable::Protocols::Base
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Unsubscribe from a stream.
|
|
66
|
+
#
|
|
67
|
+
# @param connection [Rage::Cable::WebSocketConnection] the connection object
|
|
68
|
+
# @param name [String] the stream name
|
|
69
|
+
# @param params [Hash] parameters associated with the client
|
|
70
|
+
def unsubscribe(connection, name, params)
|
|
71
|
+
connection.unsubscribe("cable:#{name}:#{stream_id(params)}")
|
|
72
|
+
end
|
|
73
|
+
|
|
65
74
|
# Broadcast data to all clients connected to a stream.
|
|
66
75
|
#
|
|
67
76
|
# @param name [String] the stream name
|
|
@@ -141,4 +141,9 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
|
|
|
141
141
|
def self.subscribe(connection, name, _)
|
|
142
142
|
super(connection, name, "")
|
|
143
143
|
end
|
|
144
|
+
|
|
145
|
+
# @private
|
|
146
|
+
def self.unsubscribe(connection, name, _)
|
|
147
|
+
super(connection, name, "")
|
|
148
|
+
end
|
|
144
149
|
end
|
data/lib/rage/configuration.rb
CHANGED
data/lib/rage/controller/api.rb
CHANGED
|
@@ -484,23 +484,31 @@ class RageController::API
|
|
|
484
484
|
#
|
|
485
485
|
# @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
|
|
486
486
|
# @param plain [String] send a text response to the client
|
|
487
|
+
# @param sse [#each, Proc, #to_json] send an SSE response to the client
|
|
487
488
|
# @param status [Integer, Symbol] set a response status
|
|
488
|
-
# @example
|
|
489
|
+
# @example Render a JSON object
|
|
489
490
|
# render json: { hello: "world" }
|
|
490
|
-
# @example
|
|
491
|
+
# @example Set a response status
|
|
491
492
|
# render status: :ok
|
|
492
|
-
# @example
|
|
493
|
-
# render
|
|
493
|
+
# @example Render an SSE stream
|
|
494
|
+
# render sse: "hello world".each_char
|
|
495
|
+
# @example Render a one-off SSE update
|
|
496
|
+
# render sse: { message: "hello world" }
|
|
497
|
+
# @example Write to an SSE connection manually
|
|
498
|
+
# render sse: ->(connection) do
|
|
499
|
+
# connection.write("data: Hello, World!\n\n")
|
|
500
|
+
# connection.close
|
|
501
|
+
# end
|
|
494
502
|
# @note `render` doesn't terminate execution of the action, so if you want to exit an action after rendering, you need to do something like `render(...) and return`.
|
|
495
|
-
def render(json: nil, plain: nil, status: nil)
|
|
496
|
-
raise "Render was called multiple times in this action" if @__rendered
|
|
503
|
+
def render(json: nil, plain: nil, sse: nil, status: nil)
|
|
504
|
+
raise "Render was called multiple times in this action." if @__rendered
|
|
497
505
|
@__rendered = true
|
|
498
506
|
|
|
499
507
|
if json || plain
|
|
500
508
|
@__body << if json
|
|
501
509
|
json.is_a?(String) ? json : json.to_json
|
|
502
510
|
else
|
|
503
|
-
|
|
511
|
+
@__headers["content-type"] = "text/plain; charset=utf-8"
|
|
504
512
|
plain.to_s
|
|
505
513
|
end
|
|
506
514
|
|
|
@@ -514,6 +522,20 @@ class RageController::API
|
|
|
514
522
|
status
|
|
515
523
|
end
|
|
516
524
|
end
|
|
525
|
+
|
|
526
|
+
if sse
|
|
527
|
+
raise ArgumentError, "Cannot render both a standard body and an SSE stream." unless @__body.empty?
|
|
528
|
+
|
|
529
|
+
if status
|
|
530
|
+
return if @__status == 204
|
|
531
|
+
raise ArgumentError, "SSE responses only support 200 and 204 statuses." if @__status != 200
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
@__env["rack.upgrade?"] = :sse
|
|
535
|
+
@__env["rack.upgrade"] = Rage::SSE::Application.new(sse)
|
|
536
|
+
@__status = 200
|
|
537
|
+
@__headers["content-type"] = "text/event-stream; charset=utf-8"
|
|
538
|
+
end
|
|
517
539
|
end
|
|
518
540
|
|
|
519
541
|
# Send a response with no body.
|
data/lib/rage/cookies.rb
CHANGED
|
@@ -16,7 +16,7 @@ end
|
|
|
16
16
|
# Cookies provide a convenient way to store small amounts of data on the client side that persists across requests.
|
|
17
17
|
# They are commonly used for session management, personalization, and tracking user preferences.
|
|
18
18
|
#
|
|
19
|
-
# Rage cookies support
|
|
19
|
+
# Rage cookies support simple, signed, and encrypted cookies.
|
|
20
20
|
#
|
|
21
21
|
# To use cookies, add the `domain_name` gem to your `Gemfile`:
|
|
22
22
|
#
|
|
@@ -24,7 +24,7 @@ end
|
|
|
24
24
|
# bundle add domain_name
|
|
25
25
|
# ```
|
|
26
26
|
#
|
|
27
|
-
# Additionally, if you need to use encrypted cookies, see {Session} for setup steps.
|
|
27
|
+
# Additionally, if you need to use signed or encrypted cookies, see {Session} for setup steps.
|
|
28
28
|
#
|
|
29
29
|
# ## Usage
|
|
30
30
|
#
|
|
@@ -70,6 +70,18 @@ end
|
|
|
70
70
|
#
|
|
71
71
|
# ```
|
|
72
72
|
#
|
|
73
|
+
# ### Signed Cookies
|
|
74
|
+
#
|
|
75
|
+
# Store readable values with tamper protection:
|
|
76
|
+
#
|
|
77
|
+
# ```ruby
|
|
78
|
+
# # Set a signed cookie
|
|
79
|
+
# cookies.signed[:user_id] = 123
|
|
80
|
+
#
|
|
81
|
+
# # Read a signed cookie
|
|
82
|
+
# cookies.signed[:user_id] # => "123"
|
|
83
|
+
# ```
|
|
84
|
+
#
|
|
73
85
|
# ### Permanent Cookies
|
|
74
86
|
#
|
|
75
87
|
# Create cookies that expire 20 years from now:
|
|
@@ -146,6 +158,17 @@ class Rage::Cookies
|
|
|
146
158
|
dup.tap { |c| c.jar = EncryptedJar }
|
|
147
159
|
end
|
|
148
160
|
|
|
161
|
+
# Returns a jar that'll automatically sign cookie values before sending them to the client and verify them
|
|
162
|
+
# for read. If the cookie was tampered with by the user (or a 3rd party), `nil` will be returned.
|
|
163
|
+
#
|
|
164
|
+
# This jar requires that you set a suitable secret for the verification on your app's `secret_key_base`.
|
|
165
|
+
#
|
|
166
|
+
# @example
|
|
167
|
+
# cookies.signed[:user_id] = current_user.id
|
|
168
|
+
def signed
|
|
169
|
+
dup.tap { |c| c.jar = SignedJar }
|
|
170
|
+
end
|
|
171
|
+
|
|
149
172
|
# Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now.
|
|
150
173
|
#
|
|
151
174
|
# @example
|
|
@@ -249,11 +272,43 @@ class Rage::Cookies
|
|
|
249
272
|
end
|
|
250
273
|
end
|
|
251
274
|
|
|
275
|
+
module RbNaClKeyBuilder
|
|
276
|
+
RBNACL_MIN_VERSION = Gem::Version.create("3.3.0")
|
|
277
|
+
RBNACL_MAX_VERSION = Gem::Version.create("8.0.0")
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def ensure_rbnacl!(purpose:)
|
|
282
|
+
return if defined?(RbNaCl) &&
|
|
283
|
+
Gem::Version.create(RbNaCl::VERSION) >= RBNACL_MIN_VERSION &&
|
|
284
|
+
Gem::Version.create(RbNaCl::VERSION) < RBNACL_MAX_VERSION
|
|
285
|
+
|
|
286
|
+
fail <<~ERR
|
|
287
|
+
|
|
288
|
+
Rage depends on `rbnacl` [>= #{RBNACL_MIN_VERSION}, < #{RBNACL_MAX_VERSION}] to support #{purpose}. Ensure the following line is added to your Gemfile:
|
|
289
|
+
gem "rbnacl"
|
|
290
|
+
|
|
291
|
+
ERR
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def build_key(secret, purpose:)
|
|
295
|
+
ensure_rbnacl!(purpose: purpose)
|
|
296
|
+
|
|
297
|
+
if !secret
|
|
298
|
+
raise "Rage.config.secret_key_base should be set to use #{purpose}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
RbNaCl::Hash.blake2b("", key: [secret].pack("H*"), digest_size: 32, personal: purpose)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
252
305
|
class EncryptedJar
|
|
253
|
-
|
|
306
|
+
PURPOSE = "encrypted cookie"
|
|
254
307
|
PADDING = "00"
|
|
255
308
|
|
|
256
309
|
class << self
|
|
310
|
+
include RbNaClKeyBuilder
|
|
311
|
+
|
|
257
312
|
def load(value)
|
|
258
313
|
box = primary_box
|
|
259
314
|
|
|
@@ -282,38 +337,108 @@ class Rage::Cookies
|
|
|
282
337
|
private
|
|
283
338
|
|
|
284
339
|
def primary_box
|
|
285
|
-
@primary_box ||=
|
|
286
|
-
if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0"))
|
|
287
|
-
fail <<~ERR
|
|
288
|
-
|
|
289
|
-
Rage depends on `rbnacl` [>= 3.3, < 8.0] to encrypt cookies. Ensure the following line is added to your Gemfile:
|
|
290
|
-
gem "rbnacl"
|
|
291
|
-
|
|
292
|
-
ERR
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
unless Rage.config.secret_key_base
|
|
296
|
-
raise "Rage.config.secret_key_base should be set to use encrypted cookies"
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base))
|
|
300
|
-
end
|
|
340
|
+
@primary_box ||= RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base, purpose: PURPOSE))
|
|
301
341
|
end
|
|
302
342
|
|
|
303
343
|
def fallback_boxes
|
|
304
344
|
@fallback_boxes ||= begin
|
|
305
345
|
fallbacks = Rage.config.fallback_secret_key_base.map do |key|
|
|
306
|
-
RbNaCl::SimpleBox.from_secret_key(build_key(key))
|
|
346
|
+
RbNaCl::SimpleBox.from_secret_key(build_key(key, purpose: PURPOSE))
|
|
307
347
|
end
|
|
308
348
|
|
|
309
349
|
fallbacks << RbNaCl::SimpleBox.from_secret_key(
|
|
310
|
-
RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt:
|
|
350
|
+
RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: PURPOSE)
|
|
311
351
|
)
|
|
312
352
|
end
|
|
313
353
|
end
|
|
354
|
+
end # class << self
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class SignedJar
|
|
358
|
+
PURPOSE = "signed cookie"
|
|
359
|
+
SEPARATOR = "."
|
|
360
|
+
VERSION = "00"
|
|
361
|
+
|
|
362
|
+
class << self
|
|
363
|
+
include RbNaClKeyBuilder
|
|
364
|
+
|
|
365
|
+
def load(value)
|
|
366
|
+
version, encoded_value, digest = parse_signed_cookie(value)
|
|
367
|
+
return nil if digest.nil?
|
|
368
|
+
|
|
369
|
+
return nil unless version == VERSION
|
|
370
|
+
|
|
371
|
+
signed_payload = signed_payload_for(version, encoded_value)
|
|
372
|
+
return Base64.urlsafe_decode64(encoded_value) if verify_digest?(signed_payload, digest)
|
|
373
|
+
|
|
374
|
+
Rage.logger.debug("Failed to verify signed cookie")
|
|
375
|
+
nil
|
|
376
|
+
rescue ArgumentError
|
|
377
|
+
Rage.logger.debug("Failed to decode signed cookie")
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def dump(value)
|
|
382
|
+
encoded_value = Base64.urlsafe_encode64(value.to_s)
|
|
383
|
+
signed_payload = signed_payload_for(VERSION, encoded_value)
|
|
384
|
+
"#{signed_payload}#{SEPARATOR}#{digest_for(signed_payload, primary_signer)}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def parse_signed_cookie(value)
|
|
390
|
+
parts = value.to_s.split(SEPARATOR, 3)
|
|
391
|
+
return [nil, nil, nil] unless parts.length == 3
|
|
392
|
+
|
|
393
|
+
version, encoded_value, digest = parts
|
|
394
|
+
return [nil, nil, nil] if version.empty? || digest.empty?
|
|
395
|
+
|
|
396
|
+
[version, encoded_value, digest]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def signed_payload_for(version, encoded_value)
|
|
400
|
+
"#{version}#{SEPARATOR}#{encoded_value}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def primary_signer
|
|
404
|
+
@primary_signer ||= RbNaCl::HMAC::SHA512256.new(
|
|
405
|
+
build_key(Rage.config.secret_key_base, purpose: PURPOSE)
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def fallback_signers
|
|
410
|
+
@fallback_signers ||= Rage.config.fallback_secret_key_base.map do |key|
|
|
411
|
+
RbNaCl::HMAC::SHA512256.new(build_key(key, purpose: PURPOSE))
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def digest_for(value, signer)
|
|
416
|
+
Base64.urlsafe_encode64(signer.auth(value))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def verify_digest?(signed_payload, digest)
|
|
420
|
+
decoded_digest = Base64.urlsafe_decode64(digest)
|
|
421
|
+
signer = primary_signer
|
|
422
|
+
i = 0
|
|
423
|
+
while true
|
|
424
|
+
begin
|
|
425
|
+
if signer.verify(decoded_digest, signed_payload)
|
|
426
|
+
return true
|
|
427
|
+
end
|
|
428
|
+
rescue RbNaCl::BadAuthenticatorError, RbNaCl::CryptoError
|
|
429
|
+
# digest does not match this signer; continue to fallback keys
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
signer = fallback_signers[i]
|
|
433
|
+
break if signer.nil?
|
|
434
|
+
|
|
435
|
+
Rage.logger.debug { "Trying to verify signed cookie with fallback key ##{i + 1}" }
|
|
436
|
+
i += 1
|
|
437
|
+
end
|
|
314
438
|
|
|
315
|
-
|
|
316
|
-
|
|
439
|
+
false
|
|
440
|
+
rescue ArgumentError
|
|
441
|
+
false
|
|
317
442
|
end
|
|
318
443
|
end # class << self
|
|
319
444
|
end
|
data/lib/rage/fiber_scheduler.rb
CHANGED
|
@@ -129,8 +129,12 @@ class Rage::OpenAPI::Converter
|
|
|
129
129
|
|
|
130
130
|
node.auth.filter_map do |auth_entry|
|
|
131
131
|
if available_before_actions.any? { |action_entry| action_entry[:name] == auth_entry[:method].to_sym }
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
if auth_entry[:ref]
|
|
133
|
+
auth_name = auth_entry[:name]
|
|
134
|
+
else
|
|
135
|
+
auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
|
|
136
|
+
@used_security_schemes << auth_entry.merge(name: auth_name)
|
|
137
|
+
end
|
|
134
138
|
|
|
135
139
|
{ auth_name => [] }
|
|
136
140
|
end
|
data/lib/rage/openapi/parser.rb
CHANGED
|
@@ -44,25 +44,28 @@ class Rage::OpenAPI::Parser
|
|
|
44
44
|
parse_response_tag(expression, node, comments[i])
|
|
45
45
|
|
|
46
46
|
elsif expression =~ /@auth\s/
|
|
47
|
-
method,
|
|
47
|
+
method, name_or_ref, tail_name = expression[6..].split(" ", 3)
|
|
48
48
|
children = find_children(comments[i + 1..], node)
|
|
49
49
|
|
|
50
50
|
if tail_name
|
|
51
51
|
Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
auth_entry =
|
|
54
|
+
auth_entry = parse_auth_tag(
|
|
55
55
|
method:,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
name_or_ref:,
|
|
57
|
+
children:,
|
|
58
|
+
comment: comments[i]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if auth_entry
|
|
62
|
+
if !node.controller.__before_action_exists?(method.to_sym)
|
|
63
|
+
Rage::OpenAPI.__log_warn "referenced before action `#{method}` is not defined in #{node.controller} at #{location_msg(comments[i])}; ensure a corresponding `before_action` call exists"
|
|
64
|
+
elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
|
|
65
|
+
Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
|
|
66
|
+
else
|
|
67
|
+
node.auth << auth_entry
|
|
68
|
+
end
|
|
66
69
|
end
|
|
67
70
|
end
|
|
68
71
|
|
|
@@ -211,6 +214,48 @@ class Rage::OpenAPI::Parser
|
|
|
211
214
|
end
|
|
212
215
|
end
|
|
213
216
|
|
|
217
|
+
def parse_auth_tag(method:, name_or_ref:, children:, comment:)
|
|
218
|
+
if method&.start_with?("#/components/securitySchemes/")
|
|
219
|
+
Rage::OpenAPI.__log_warn "invalid `@auth` shared reference syntax detected at #{location_msg(comment)}; use `@auth <before_action> #{method}` and remove child definitions"
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if name_or_ref&.start_with?("#/components")
|
|
224
|
+
ref = parse_auth_shared_reference(name_or_ref, comment)
|
|
225
|
+
return if ref.nil?
|
|
226
|
+
|
|
227
|
+
if children.any?
|
|
228
|
+
Rage::OpenAPI.__log_warn "ignored child `@auth` definition detected at #{location_msg(comment)}; shared security scheme references do not support child definitions"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
method:,
|
|
233
|
+
name: ref.delete_prefix("#/components/securitySchemes/"),
|
|
234
|
+
ref: { "$ref" => ref }
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
{
|
|
239
|
+
method:,
|
|
240
|
+
name: name_or_ref || method,
|
|
241
|
+
definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_auth_shared_reference(name_or_ref, comment)
|
|
246
|
+
shared_reference_parser = Rage::OpenAPI::Parsers::SharedReference.new
|
|
247
|
+
is_valid_ref = shared_reference_parser.parse(name_or_ref)
|
|
248
|
+
has_valid_prefix = name_or_ref.start_with?("#/components/securitySchemes/")
|
|
249
|
+
scheme_name = name_or_ref.delete_prefix("#/components/securitySchemes/")
|
|
250
|
+
|
|
251
|
+
if is_valid_ref && has_valid_prefix && !scheme_name.empty? && !scheme_name.include?("/")
|
|
252
|
+
name_or_ref
|
|
253
|
+
else
|
|
254
|
+
Rage::OpenAPI.__log_warn "invalid shared reference detected at #{location_msg(comment)}"
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
214
259
|
def parse_param_tag(expression, node, comment)
|
|
215
260
|
param = expression[7..].strip
|
|
216
261
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# @private
|
|
4
|
+
# This class is responsible for handling the lifecycle of an SSE connection.
|
|
5
|
+
# It determines the type of SSE stream and manages the data flow.
|
|
6
|
+
#
|
|
7
|
+
class Rage::SSE::Application
|
|
8
|
+
def initialize(stream)
|
|
9
|
+
@stream = stream
|
|
10
|
+
|
|
11
|
+
@type = if @stream.is_a?(Enumerator)
|
|
12
|
+
:stream
|
|
13
|
+
elsif @stream.is_a?(Proc)
|
|
14
|
+
:manual
|
|
15
|
+
else
|
|
16
|
+
:single
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@log_tags, @log_context = Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def on_open(connection)
|
|
23
|
+
@type == :single ? send_data(connection) : start_stream(connection)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def send_data(connection)
|
|
29
|
+
Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
|
|
30
|
+
connection.write(Rage::SSE.__serialize(@stream))
|
|
31
|
+
connection.close
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start_stream(connection)
|
|
36
|
+
Fiber.schedule do
|
|
37
|
+
Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context] = @log_tags, @log_context
|
|
38
|
+
Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
|
|
39
|
+
@type == :stream ? start_formatted_stream(connection) : start_raw_stream(connection)
|
|
40
|
+
end
|
|
41
|
+
rescue => e
|
|
42
|
+
Rage.logger.error("SSE stream failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start_formatted_stream(connection)
|
|
47
|
+
@stream.each do |event|
|
|
48
|
+
break if !connection.open?
|
|
49
|
+
connection.write(Rage::SSE.__serialize(event)) if event
|
|
50
|
+
end
|
|
51
|
+
ensure
|
|
52
|
+
connection.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_raw_stream(connection)
|
|
56
|
+
@stream.call(Rage::SSE::ConnectionProxy.new(connection))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# This class acts as a proxy for the underlying SSE connection, providing a simplified and safe interface for interacting with the stream.
|
|
5
|
+
# It ensures that operations are only performed on an open connection and abstracts away the direct connection handling.
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# ```ruby
|
|
10
|
+
# render sse: ->(connection) do
|
|
11
|
+
# # `connection` is an instance of Rage::SSE::ConnectionProxy
|
|
12
|
+
# end
|
|
13
|
+
# ```
|
|
14
|
+
#
|
|
15
|
+
class Rage::SSE::ConnectionProxy
|
|
16
|
+
# @private
|
|
17
|
+
def initialize(connection)
|
|
18
|
+
@connection = connection
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Writes data to the SSE stream.
|
|
22
|
+
# @param data [#to_s]
|
|
23
|
+
# @raise [IOError] if the stream is already closed.
|
|
24
|
+
def write(data)
|
|
25
|
+
raise IOError, "closed stream" unless @connection.open?
|
|
26
|
+
@connection.write(data.to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias_method :<<, :write
|
|
30
|
+
|
|
31
|
+
# Closes the SSE stream.
|
|
32
|
+
def close
|
|
33
|
+
@connection.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
alias_method :close_write, :close
|
|
37
|
+
|
|
38
|
+
# Checks if the SSE stream is closed.
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def closed?
|
|
41
|
+
!@connection.open?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A no-op method to maintain interface compatibility.
|
|
45
|
+
# Flushing is handled by the underlying connection.
|
|
46
|
+
# @raise [IOError] if the stream is already closed.
|
|
47
|
+
def flush
|
|
48
|
+
raise IOError, "closed stream" unless @connection.open?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A no-op method to maintain interface compatibility.
|
|
52
|
+
# Reading from an SSE stream is not supported on the server side.
|
|
53
|
+
def read(...)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A no-op method to maintain interface compatibility.
|
|
57
|
+
# Reading from an SSE stream is not supported on the server side.
|
|
58
|
+
def close_read
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Represents a single Server-Sent Event. This class allows you to define the `id`, `event`, and `retry` fields for an SSE message.
|
|
4
|
+
#
|
|
5
|
+
# @!attribute id
|
|
6
|
+
# @return [String] The `id` field for the SSE event. This can be used to track messages.
|
|
7
|
+
# @!attribute event
|
|
8
|
+
# @return [String] The `event` field for the SSE event. This can be used to define custom event types.
|
|
9
|
+
# @!attribute retry
|
|
10
|
+
# @return [Integer] The `retry` field for the SSE event, in milliseconds. This value is a suggestion for the client about how long to wait before reconnecting.
|
|
11
|
+
# @!attribute data
|
|
12
|
+
# @return [String, #to_json] The `data` field for the SSE event. If the object provided is not a string, it will be serialized to JSON.
|
|
13
|
+
Rage::SSE::Message = Struct.new(:id, :event, :retry, :data, keyword_init: true) do
|
|
14
|
+
def to_s
|
|
15
|
+
data_entry = if !data.is_a?(String)
|
|
16
|
+
"data: #{data.to_json}\n"
|
|
17
|
+
elsif data.include?("\n")
|
|
18
|
+
data.split("\n").map { |d| "data: #{d}\n" }.join
|
|
19
|
+
else
|
|
20
|
+
"data: #{data}\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
"#{data_entry}#{"id: #{id}\n" if id}#{"event: #{event}\n" if event}#{"retry: #{self.retry.to_i}\n" if self.retry && self.retry.to_i > 0}\n"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/rage/sse/sse.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rage::SSE
|
|
4
|
+
# A factory method for creating Server-Sent Events.
|
|
5
|
+
#
|
|
6
|
+
# @param data [String, #to_json] The `data` field for the SSE event. If the object provided is not a string, it will be serialized to JSON.
|
|
7
|
+
# @param id [String, nil] The `id` field for the SSE event. This can be used to track messages.
|
|
8
|
+
# @param event [String, nil] The `event` field for the SSE event. This can be used to define custom event types.
|
|
9
|
+
# @param retry [Integer, nil] The `retry` field for the SSE event, in milliseconds. This value is used to instruct the client how long to wait before attempting to reconnect.
|
|
10
|
+
# @return [Message] The formatted SSE event.
|
|
11
|
+
# @example
|
|
12
|
+
# render sse: Rage::SSE.message(current_user.profile, id: current_user.id)
|
|
13
|
+
def self.message(data, id: nil, event: nil, retry: nil)
|
|
14
|
+
Message.new(data:, id:, event:, retry:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @private
|
|
18
|
+
def self.__serialize(data)
|
|
19
|
+
if data.is_a?(String)
|
|
20
|
+
data.include?("\n") ? Message.new(data:).to_s : "data: #{data}\n\n"
|
|
21
|
+
elsif data.is_a?(Message)
|
|
22
|
+
data.to_s
|
|
23
|
+
else
|
|
24
|
+
"data: #{data.to_json}\n\n"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
require_relative "application"
|
|
30
|
+
require_relative "connection_proxy"
|
|
31
|
+
require_relative "message"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
##
|
|
4
|
-
# The **core.fiber.dispatch** span tracks the scheduling and processing of system-level fibers created by the framework to process requests
|
|
4
|
+
# The **core.fiber.dispatch** span tracks the scheduling and processing of system-level fibers created by the framework to process requests, deferred tasks, or SSE streams.
|
|
5
5
|
#
|
|
6
6
|
# This span is started when a system fiber begins processing and ends when the fiber has completed processing.
|
|
7
7
|
# See {handle handle} for the list of arguments passed to handler methods.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# The **sse.stream.process** span wraps the processing of an SSE stream.
|
|
5
|
+
#
|
|
6
|
+
# This span starts when a connection is opened and ends when the stream is finished.
|
|
7
|
+
# See {handle handle} for the list of arguments passed to handler methods.
|
|
8
|
+
#
|
|
9
|
+
# @see Rage::Telemetry::Handler Rage::Telemetry::Handler
|
|
10
|
+
#
|
|
11
|
+
class Rage::Telemetry::Spans::ProcessSSEStream
|
|
12
|
+
class << self
|
|
13
|
+
# @private
|
|
14
|
+
def id
|
|
15
|
+
"sse.stream.process"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @private
|
|
19
|
+
def span_parameters
|
|
20
|
+
%w[connection: type:]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @private
|
|
24
|
+
def handler_arguments
|
|
25
|
+
{
|
|
26
|
+
name: '"SSE.process"',
|
|
27
|
+
env: "connection.env",
|
|
28
|
+
type: "type"
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @!parse [ruby]
|
|
33
|
+
# # @param id ["sse.stream.process"] ID of the span
|
|
34
|
+
# # @param name ["SSE.process"] human-readable name of the operation
|
|
35
|
+
# # @param env [Hash] the Rack environment associated with the connection
|
|
36
|
+
# # @param type [:stream, :single, :manual] the type of the SSE stream
|
|
37
|
+
# # @yieldreturn [Rage::Telemetry::SpanResult]
|
|
38
|
+
# #
|
|
39
|
+
# # @example
|
|
40
|
+
# # class MyTelemetryHandler < Rage::Telemetry::Handler
|
|
41
|
+
# # handle "sse.stream.process", with: :my_handler
|
|
42
|
+
# #
|
|
43
|
+
# # def my_handler(id:, name:, env:, type:)
|
|
44
|
+
# # yield
|
|
45
|
+
# # end
|
|
46
|
+
# # end
|
|
47
|
+
# # @note Rage automatically detects which parameters your handler method accepts and only passes those parameters.
|
|
48
|
+
# # You can omit any of the parameters described here.
|
|
49
|
+
# def handle(id:, name:, env:, type:)
|
|
50
|
+
# end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -46,12 +46,13 @@ module Rage::Telemetry
|
|
|
46
46
|
|
|
47
47
|
# @private
|
|
48
48
|
def self.tracer
|
|
49
|
-
@tracer ||= Tracer.new(__registry
|
|
49
|
+
@tracer ||= Tracer.new(__registry)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
# @private
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# @param handlers_map [Hash{String => Array<Rage::Telemetry::HandlerRef>}]
|
|
54
|
+
def self.__setup(handlers_map)
|
|
55
|
+
tracer.setup(handlers_map)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
##
|
|
@@ -81,6 +82,7 @@ module Rage::Telemetry
|
|
|
81
82
|
# | `deferred.task.process` | {ProcessDeferredTask} | Wraps the processing of deferred tasks |
|
|
82
83
|
# | `events.event.publish` | {PublishEvent} | Wraps the publishing of events via {Rage::Events Rage::Events} |
|
|
83
84
|
# | `events.subscriber.process` | {ProcessEventSubscriber} | Wraps the processing of events by subscribers |
|
|
85
|
+
# | `sse.stream.process` | {ProcessSSEStream} | Wraps the processing of an SSE stream |
|
|
84
86
|
#
|
|
85
87
|
module Spans
|
|
86
88
|
end
|
|
@@ -6,20 +6,18 @@ class Rage::Telemetry::Tracer
|
|
|
6
6
|
private_constant :DEFAULT_SPAN_RESULT
|
|
7
7
|
|
|
8
8
|
# @param spans_registry [Hash{String => Rage::Telemetry::Spans}]
|
|
9
|
-
|
|
10
|
-
def initialize(spans_registry, handlers_map)
|
|
9
|
+
def initialize(spans_registry)
|
|
11
10
|
@spans_registry = spans_registry
|
|
12
|
-
@handlers_map = handlers_map
|
|
13
|
-
|
|
14
|
-
@all_handler_refs = handlers_map.values.flatten
|
|
15
11
|
|
|
16
12
|
@spans_registry.each do |_, span|
|
|
17
13
|
setup_noop(span)
|
|
18
14
|
end
|
|
19
15
|
end
|
|
20
16
|
|
|
21
|
-
def setup
|
|
22
|
-
@handlers_map.
|
|
17
|
+
def setup(handlers_map)
|
|
18
|
+
@all_handler_refs = handlers_map.values.flatten
|
|
19
|
+
|
|
20
|
+
handlers_map.each do |span_id, handler_refs|
|
|
23
21
|
setup_tracer(@spans_registry[span_id], handler_refs)
|
|
24
22
|
end
|
|
25
23
|
end
|
data/lib/rage/version.rb
CHANGED
data/lib/rage-rb.rb
CHANGED
|
@@ -40,6 +40,12 @@ module Rage
|
|
|
40
40
|
Rage::Events
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Shorthand to access {Rage::SSE Rage::SSE}.
|
|
44
|
+
# @return [Rage::SSE]
|
|
45
|
+
def self.sse
|
|
46
|
+
Rage::SSE
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
# Configure routes for the Rage application.
|
|
44
50
|
# @return [Rage::Router::DSL::Handler]
|
|
45
51
|
# @example
|
|
@@ -185,6 +191,7 @@ module Rage
|
|
|
185
191
|
autoload :OpenAPI, "rage/openapi/openapi"
|
|
186
192
|
autoload :Deferred, "rage/deferred/deferred"
|
|
187
193
|
autoload :Events, "rage/events/events"
|
|
194
|
+
autoload :SSE, "rage/sse/sse"
|
|
188
195
|
end
|
|
189
196
|
|
|
190
197
|
module RageController
|
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.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Samoilov
|
|
@@ -43,14 +43,14 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '
|
|
46
|
+
version: '5.2'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '
|
|
53
|
+
version: '5.2'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: zeitwerk
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -221,6 +221,10 @@ files:
|
|
|
221
221
|
- lib/rage/rspec.rb
|
|
222
222
|
- lib/rage/session.rb
|
|
223
223
|
- lib/rage/setup.rb
|
|
224
|
+
- lib/rage/sse/application.rb
|
|
225
|
+
- lib/rage/sse/connection_proxy.rb
|
|
226
|
+
- lib/rage/sse/message.rb
|
|
227
|
+
- lib/rage/sse/sse.rb
|
|
224
228
|
- lib/rage/tasks.rb
|
|
225
229
|
- lib/rage/telemetry/handler.rb
|
|
226
230
|
- lib/rage/telemetry/spans/await_fiber.rb
|
|
@@ -233,6 +237,7 @@ files:
|
|
|
233
237
|
- lib/rage/telemetry/spans/process_controller_action.rb
|
|
234
238
|
- lib/rage/telemetry/spans/process_deferred_task.rb
|
|
235
239
|
- lib/rage/telemetry/spans/process_event_subscriber.rb
|
|
240
|
+
- lib/rage/telemetry/spans/process_sse_stream.rb
|
|
236
241
|
- lib/rage/telemetry/spans/publish_event.rb
|
|
237
242
|
- lib/rage/telemetry/spans/spawn_fiber.rb
|
|
238
243
|
- lib/rage/telemetry/telemetry.rb
|