durable_streams-rails 0.2.5 → 0.3.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: 1e6aafc0e3245798247add32c7232b17e7c54384f9ffbd58eaad6dcb64acd85b
4
- data.tar.gz: 5729cbb4cc772f1c4bf44c343294a28ecd43a3b2990846984b77ae6bf2d3f25e
3
+ metadata.gz: 5c2dc408918802eb41264671f804a2bcee440341b56b53ef2f1e58fa77b84708
4
+ data.tar.gz: '0496a9b62af4f8ed219f0f6d31df0da2e7f6c6b9ee871de91b11acc1cce8d707'
5
5
  SHA512:
6
- metadata.gz: 18f62ee08b52c3246844d1432c9e9890070a32f48ab84cad3f24e3af45cbdc254a737ed2ac67a7be2691e74c914fe90f80940ff4ad451ea7b86a36a043bc2ee2
7
- data.tar.gz: fd42543375e15f3ec827e0b7699e60f60bc4785562a8e91c6a2ce168ce5a78e3f099e1cc211f7861294f54569417931c01287917913f75ac37cee1ba884ab7e2
6
+ metadata.gz: b4c1e809fdf66dd7b648156d5574c61f8d5ca47c365c60fd2eb4c5366fc7f87ec46bdbae0c32f848c7ef1f60863c9b96f2b5a706c9baff05f2dee082284a3e24
7
+ data.tar.gz: e41d5c76287f2ccbcf8a3224bb654b40087468d84796bc5c69ed9f37f7425b49a36450bf9e4bf0daec522aaeb9bff3152adc65fb531526b748296e4ea16f2a22
data/README.md CHANGED
@@ -157,7 +157,7 @@ txid = comment.stream_upsert_to post
157
157
  comment.stream_delete_to post
158
158
  ```
159
159
 
160
- Asynchronous (via `DurableStreams::BroadcastJob`):
160
+ Asynchronous (via `DurableStreams::Rails::BroadcastJob`):
161
161
 
162
162
  ```ruby
163
163
  comment.stream_insert_later_to post
@@ -210,7 +210,7 @@ end
210
210
  The gem provides test helpers that mirror Turbo's `assert_turbo_stream_broadcasts`. They are
211
211
  automatically included in `ActiveSupport::TestCase` via the engine.
212
212
 
213
- During tests, `DurableStreams::Testing` intercepts all broadcasts at the transport layer —
213
+ During tests, `DurableStreams::Rails::Testing` intercepts all broadcasts at the transport layer —
214
214
  events are captured in-memory instead of being sent to the stream server. This means tests
215
215
  verify the Rails integration (DSL wiring, event shape, callbacks, jobs, suppression) without
216
216
  requiring a running Durable Streams server. Same pattern as Turbo, where `ActionCable::TestHelper`
@@ -249,13 +249,13 @@ This gem mirrors `turbo-rails` 1:1 in structure and design:
249
249
 
250
250
  | Turbo Rails | Durable Streams Rails | Role |
251
251
  |---|---|---|
252
- | `Turbo::Streams::StreamName` | `DurableStreams::StreamName` | Name generation (private `stream_name_from`) |
253
- | `Turbo::Streams::Broadcasts` | `DurableStreams::Broadcasts` | Flatten/compact guards, serialization, transport |
252
+ | `Turbo::Streams::StreamName` | `DurableStreams::Rails::StreamName` | Name generation (private `stream_name_from`) |
253
+ | `Turbo::Streams::Broadcasts` | `DurableStreams::Rails::Broadcasts` | Flatten/compact guards, serialization, transport |
254
254
  | `Turbo::StreamsChannel` | `DurableStreams` module | Entry point (extends StreamName + Broadcasts) |
255
- | `Turbo::Broadcastable` | `DurableStreams::Broadcastable` | Model concern, delegates to module |
256
- | `Turbo::Streams::ActionBroadcastJob` | `DurableStreams::BroadcastJob` | Async job, delegates to module |
257
- | `ActionCable::TestHelper` | `DurableStreams::Testing` | Test transport interception |
258
- | `Turbo::Broadcastable::TestHelper` | `DurableStreams::Broadcastable::TestHelper` | Test assertions |
255
+ | `Turbo::Broadcastable` | `DurableStreams::Rails::Broadcastable` | Model concern, delegates to module |
256
+ | `Turbo::Streams::ActionBroadcastJob` | `DurableStreams::Rails::BroadcastJob` | Async job, delegates to module |
257
+ | `ActionCable::TestHelper` | `DurableStreams::Rails::Testing` | Test transport interception |
258
+ | `Turbo::Broadcastable::TestHelper` | `DurableStreams::Rails::Broadcastable::TestHelper` | Test assertions |
259
259
 
260
260
  The key architectural difference: Turbo broadcasts HTML fragments over WebSockets (Action Cable).
261
261
  This gem broadcasts JSON State Protocol events over HTTP/SSE (Durable Streams).
@@ -0,0 +1,27 @@
1
+ module DurableStreams
2
+ module Rails
3
+ class AuthController < ActionController::API
4
+ def verify
5
+ if request.headers["X-Forwarded-Uri"]
6
+ stream_name ? head(:ok) : head(:unauthorized)
7
+ elsif valid_server_api_key?
8
+ head :ok
9
+ else
10
+ head :unauthorized
11
+ end
12
+ end
13
+
14
+ private
15
+ def stream_name
16
+ DurableStreams.verify_signed_url(request.headers["X-Forwarded-Uri"])
17
+ end
18
+
19
+ def valid_server_api_key?
20
+ if auth_header = request.headers["Authorization"]
21
+ auth_header.start_with?("Bearer ") &&
22
+ ActiveSupport::SecurityUtils.secure_compare(auth_header.delete_prefix("Bearer "), DurableStreams.server_api_key)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ # The job that powers all the <tt>stream_*_later_to</tt> broadcasts available in <tt>DurableStreams::Rails::Broadcastable</tt>.
2
+ module DurableStreams
3
+ module Rails
4
+ class BroadcastJob < ActiveJob::Base
5
+ discard_on ActiveJob::DeserializationError
6
+ retry_on DurableStreams::ConnectionError, wait: :polynomially_longer, attempts: 10
7
+
8
+ def perform(stream_name, type:, key:, value:, operation:)
9
+ DurableStreams.broadcast_event_to stream_name, type: type, key: key, value: value, operation: operation
10
+ end
11
+ end
12
+ end
13
+ end
@@ -47,7 +47,7 @@
47
47
  # Comment.suppressing_streams do
48
48
  # Comment.create!(post: post) # This won't broadcast the insert event
49
49
  # end
50
- module DurableStreams::Broadcastable
50
+ module DurableStreams::Rails::Broadcastable
51
51
  extend ActiveSupport::Concern
52
52
 
53
53
  included do
@@ -155,7 +155,7 @@ module DurableStreams::Broadcastable
155
155
  stream_delete_to self
156
156
  end
157
157
 
158
- # Same as <tt>#stream_insert_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
158
+ # Same as <tt>#stream_insert_to</tt> but run asynchronously via a <tt>DurableStreams::Rails::BroadcastJob</tt>.
159
159
  def stream_insert_later_to(*streamables)
160
160
  DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :insert)) unless suppressed_streams?
161
161
  end
@@ -165,7 +165,7 @@ module DurableStreams::Broadcastable
165
165
  stream_insert_later_to self
166
166
  end
167
167
 
168
- # Same as <tt>#stream_update_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
168
+ # Same as <tt>#stream_update_to</tt> but run asynchronously via a <tt>DurableStreams::Rails::BroadcastJob</tt>.
169
169
  def stream_update_later_to(*streamables)
170
170
  DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :update)) unless suppressed_streams?
171
171
  end
@@ -175,7 +175,7 @@ module DurableStreams::Broadcastable
175
175
  stream_update_later_to self
176
176
  end
177
177
 
178
- # Same as <tt>#stream_upsert_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
178
+ # Same as <tt>#stream_upsert_to</tt> but run asynchronously via a <tt>DurableStreams::Rails::BroadcastJob</tt>.
179
179
  def stream_upsert_later_to(*streamables)
180
180
  DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :upsert)) unless suppressed_streams?
181
181
  end
@@ -185,7 +185,7 @@ module DurableStreams::Broadcastable
185
185
  stream_upsert_later_to self
186
186
  end
187
187
 
188
- # Same as <tt>#stream_delete_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
188
+ # Same as <tt>#stream_delete_to</tt> but run asynchronously via a <tt>DurableStreams::Rails::BroadcastJob</tt>.
189
189
  def stream_delete_later_to(*streamables)
190
190
  DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :delete, value: nil)) unless suppressed_streams?
191
191
  end
data/config/routes.rb CHANGED
@@ -1,3 +1,3 @@
1
- Rails.application.routes.draw do
2
- get "durable_streams/auth/verify", to: "durable_streams/auth#verify", as: :durable_streams_auth_verify
1
+ ::Rails.application.routes.draw do
2
+ get "durable_streams/auth/verify", to: "durable_streams/rails/auth#verify", as: :durable_streams_auth_verify
3
3
  end if DurableStreams.draw_routes
@@ -0,0 +1,159 @@
1
+ module DurableStreams
2
+ module Rails
3
+ module Broadcastable
4
+ module TestHelper
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include DurableStreams::Rails::StreamName
9
+
10
+ setup { DurableStreams::Rails::Testing.install! }
11
+ teardown { DurableStreams::Rails::Testing.reset! }
12
+ end
13
+
14
+ # Asserts that State Protocol events were broadcast to a stream
15
+ #
16
+ # ==== Arguments
17
+ #
18
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
19
+ # stream name, or the name itself
20
+ # * <tt>&block</tt> optional block executed before the
21
+ # assertion
22
+ #
23
+ # ==== Options
24
+ #
25
+ # * <tt>count:</tt> the number of events that are expected to be broadcast
26
+ #
27
+ # Asserts events were broadcast:
28
+ #
29
+ # comment = Comment.find(1)
30
+ # comment.stream_insert_to comment.post
31
+ #
32
+ # assert_stream_broadcasts comment.post
33
+ #
34
+ # Asserts that two events were broadcast:
35
+ #
36
+ # comment = Comment.find(1)
37
+ # comment.stream_insert_to comment.post
38
+ # comment.stream_update_to comment.post
39
+ #
40
+ # assert_stream_broadcasts comment.post, count: 2
41
+ #
42
+ # You can pass a block to run before the assertion:
43
+ #
44
+ # comment = Comment.find(1)
45
+ #
46
+ # assert_stream_broadcasts comment.post do
47
+ # comment.stream_insert_to comment.post
48
+ # end
49
+ #
50
+ # In addition to a String, the helper also accepts an Object or Array to
51
+ # determine the stream name:
52
+ #
53
+ # post = Post.find(1)
54
+ #
55
+ # assert_stream_broadcasts post do
56
+ # post.comments.create!(body: "Hello")
57
+ # end
58
+ #
59
+ def assert_stream_broadcasts(stream_name_or_object, count: nil, &block)
60
+ payloads = capture_stream_broadcasts(stream_name_or_object, &block)
61
+ stream_name = stream_name_from(stream_name_or_object)
62
+
63
+ if count.nil?
64
+ assert_not_empty payloads, "Expected at least one broadcast on #{stream_name.inspect}, but there were none"
65
+ else
66
+ broadcasts = "Durable Stream broadcast".pluralize(count)
67
+
68
+ assert count == payloads.count, "Expected #{count} #{broadcasts} on #{stream_name.inspect}, but there were #{payloads.count}"
69
+ end
70
+ end
71
+
72
+ # Asserts that no State Protocol events were broadcast to a stream
73
+ #
74
+ # ==== Arguments
75
+ #
76
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
77
+ # stream name, or the name itself
78
+ # * <tt>&block</tt> optional block executed before the
79
+ # assertion
80
+ #
81
+ # Asserts that no events were broadcast:
82
+ #
83
+ # assert_no_stream_broadcasts "messages" do
84
+ # # do something other than broadcast to "messages"
85
+ # end
86
+ #
87
+ # In addition to a String, the helper also accepts an Object or Array to
88
+ # determine the stream name:
89
+ #
90
+ # post = Post.find(1)
91
+ #
92
+ # assert_no_stream_broadcasts post do
93
+ # # do something other than broadcast to post's stream
94
+ # end
95
+ #
96
+ def assert_no_stream_broadcasts(stream_name_or_object, &block)
97
+ block&.call
98
+
99
+ stream_name = stream_name_from(stream_name_or_object)
100
+ payloads = stream_broadcasts_for(stream_name)
101
+
102
+ assert payloads.empty?, "Expected no broadcasts on #{stream_name.inspect}, but there were #{payloads.count}"
103
+ end
104
+
105
+ # Captures any State Protocol events that were broadcast to a stream
106
+ #
107
+ # ==== Arguments
108
+ #
109
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
110
+ # stream name, or the name itself
111
+ # * <tt>&block</tt> optional block to capture broadcasts during execution
112
+ #
113
+ # Returns any events that have been broadcast as an Array of parsed JSON hashes
114
+ #
115
+ # comment = Comment.find(1)
116
+ # comment.stream_insert_to comment.post
117
+ # comment.stream_update_to comment.post
118
+ #
119
+ # events = capture_stream_broadcasts comment.post
120
+ #
121
+ # assert_equal "insert", events.first["headers"]["operation"]
122
+ # assert_equal "update", events.second["headers"]["operation"]
123
+ #
124
+ # You can pass a block to limit the scope of the broadcasts being captured:
125
+ #
126
+ # comment = Comment.find(1)
127
+ #
128
+ # events = capture_stream_broadcasts comment.post do
129
+ # comment.stream_insert_to comment.post
130
+ # end
131
+ #
132
+ # assert_equal "insert", events.first["headers"]["operation"]
133
+ #
134
+ def capture_stream_broadcasts(stream_name_or_object, &block)
135
+ stream_name = stream_name_from(stream_name_or_object)
136
+
137
+ if block_given?
138
+ before = stream_broadcasts_for(stream_name).size
139
+ block.call
140
+ stream_broadcasts_for(stream_name)[before..]
141
+ else
142
+ stream_broadcasts_for(stream_name)
143
+ end
144
+ end
145
+
146
+ private
147
+ def stream_broadcasts_for(stream_name)
148
+ DurableStreams::Rails::Testing.messages_for(stream_name).map do |message|
149
+ if message.is_a?(String)
150
+ JSON.parse(message)
151
+ else
152
+ message
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -1,10 +1,10 @@
1
1
  # Provides the broadcast actions in synchronous and asynchronous form for the <tt>DurableStreams</tt> module.
2
- # See <tt>DurableStreams::Broadcastable</tt> for the user-facing API that invokes these methods with most of the
2
+ # See <tt>DurableStreams::Rails::Broadcastable</tt> for the user-facing API that invokes these methods with most of the
3
3
  # paperwork filled out already.
4
4
  #
5
5
  # Can be used directly using something like <tt>DurableStreams.broadcast_event_to :room, type: "message", key: "5",
6
6
  # value: { content: "Hello" }, operation: :insert</tt>.
7
- module DurableStreams::Broadcasts
7
+ module DurableStreams::Rails::Broadcasts
8
8
  def broadcast_event_to(*streamables, type:, key:, value:, operation:)
9
9
  streamables.flatten!
10
10
  streamables.compact_blank!
@@ -29,7 +29,7 @@ module DurableStreams::Broadcasts
29
29
  streamables.compact_blank!
30
30
 
31
31
  if streamables.present?
32
- DurableStreams::BroadcastJob.perform_later \
32
+ DurableStreams::Rails::BroadcastJob.perform_later \
33
33
  stream_name_from(streamables),
34
34
  type: type, key: key, value: value, operation: operation.to_s
35
35
  end
@@ -46,8 +46,8 @@ module DurableStreams::Broadcasts
46
46
 
47
47
  private
48
48
  def append_to_stream(stream_name, message)
49
- if DurableStreams::Testing.recording?
50
- DurableStreams::Testing.record(stream_name, message)
49
+ if DurableStreams::Rails::Testing.recording?
50
+ DurableStreams::Rails::Testing.record(stream_name, message)
51
51
  else
52
52
  stream(stream_name).append(message)
53
53
  end
@@ -0,0 +1,71 @@
1
+ require "rails/engine"
2
+
3
+ module DurableStreams
4
+ module Rails
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace DurableStreams::Rails
7
+ config.durable_streams = ActiveSupport::OrderedOptions.new
8
+ config.autoload_once_paths = %W[
9
+ #{root}/app/controllers
10
+ #{root}/app/models
11
+ #{root}/app/models/concerns
12
+ #{root}/app/jobs
13
+ ]
14
+
15
+ # If the parent application does not use Active Job, app/jobs cannot
16
+ # be eager loaded, because it references the ActiveJob constant.
17
+ initializer "durable_streams_rails.no_active_job", before: :set_eager_load_paths do
18
+ unless defined?(ActiveJob)
19
+ ::Rails.autoloaders.once.do_not_eager_load("#{root}/app/jobs")
20
+ end
21
+ end
22
+
23
+ initializer "durable_streams_rails.broadcastable" do
24
+ ActiveSupport.on_load(:active_record) do
25
+ if defined?(ActiveJob)
26
+ include DurableStreams::Rails::Broadcastable
27
+ end
28
+ end
29
+ end
30
+
31
+ initializer "durable_streams_rails.configs" do
32
+ config.after_initialize do |app|
33
+ DurableStreams.draw_routes = app.config.durable_streams.draw_routes != false
34
+ end
35
+ end
36
+
37
+ initializer "durable_streams_rails.signed_stream_verifier_key" do
38
+ config.after_initialize do
39
+ DurableStreams.signed_stream_verifier_key =
40
+ config.durable_streams.signed_stream_verifier_key ||
41
+ ::Rails.application.key_generator.generate_key("durable_streams/signed_stream_verifier_key")
42
+ end
43
+ end
44
+
45
+ initializer "durable_streams_rails.server_api_key" do
46
+ config.after_initialize do
47
+ DurableStreams.server_api_key =
48
+ config.durable_streams.server_api_key ||
49
+ ::Rails.application.key_generator.generate_key("durable_streams/server_api_key").unpack1("H*")
50
+
51
+ if DurableStreams.base_url.present?
52
+ DurableStreams.configure do |c|
53
+ c.base_url = DurableStreams.base_url
54
+ c.default_headers = { "Authorization" => "Bearer #{DurableStreams.server_api_key}" }
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # No Action Cable dependency -- load test helpers directly.
61
+ initializer "durable_streams_rails.test_assertions" do
62
+ ActiveSupport.on_load(:active_support_test_case) do
63
+ if defined?(ActiveJob)
64
+ require "durable_streams/rails/broadcastable/test_helper"
65
+ include DurableStreams::Rails::Broadcastable::TestHelper
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,64 @@
1
+ require "yaml"
2
+ require "erb"
3
+
4
+ module DurableStreams
5
+ module Rails
6
+ class ServerConfig
7
+ TEMPLATE = ERB.new(<<~'CADDYFILE', trim_mode: "-")
8
+ {
9
+ admin off
10
+ <%- unless @config["domain"] -%>
11
+ auto_https off
12
+ <%- end -%>
13
+ }
14
+
15
+ <%= address %> {
16
+ route <%= route_path %> {
17
+ forward_auth <%= auth["url"] %> {
18
+ uri <%= auth["path"] %>
19
+ <%- copy_headers.each do |header| -%>
20
+ copy_headers <%= header %>
21
+ <%- end -%>
22
+ }
23
+ durable_streams<%= storage_block %>
24
+ }
25
+ }
26
+ CADDYFILE
27
+
28
+ def self.generate(config_path, environment)
29
+ new(config_path, environment).generate
30
+ end
31
+
32
+ def initialize(config_path, environment)
33
+ @config = YAML.load_file(config_path, aliases: true).fetch(environment)
34
+ end
35
+
36
+ def generate
37
+ TEMPLATE.result(binding)
38
+ end
39
+
40
+ private
41
+ def address
42
+ @config["domain"] || ":#{@config.fetch("port", 4437)}"
43
+ end
44
+
45
+ def route_path
46
+ @config.fetch("route", "/v1/streams/*")
47
+ end
48
+
49
+ def auth
50
+ @config.fetch("auth")
51
+ end
52
+
53
+ def copy_headers
54
+ auth.fetch("copy_headers", [ "Cookie" ])
55
+ end
56
+
57
+ def storage_block
58
+ if dir = @config.dig("storage", "data_dir")
59
+ " {\n\t\t\tdata_dir #{dir}\n\t\t}"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -2,7 +2,7 @@
2
2
  # are exposed directly to the client via signed URL tokens, we need to ensure that the name isn't
3
3
  # tampered with, so the names are signed upon generation and verified upon receipt. All verification
4
4
  # happens through the <tt>DurableStreams.signed_stream_verifier</tt>.
5
- module DurableStreams::StreamName
5
+ module DurableStreams::Rails::StreamName
6
6
  # Used by the stream auth endpoint to verify a signed stream name.
7
7
  def verified_stream_name(signed_stream_name)
8
8
  DurableStreams.signed_stream_verifier.verified signed_stream_name
@@ -1,12 +1,12 @@
1
1
  require "active_support/core_ext/module/attribute_accessors_per_thread"
2
2
 
3
3
  # Intercepts stream appends during tests, similar to how <tt>ActionCable::TestHelper</tt> captures broadcasts.
4
- # Activated by <tt>DurableStreams::Broadcastable::TestHelper</tt> via +setup+/+teardown+ hooks.
4
+ # Activated by <tt>DurableStreams::Rails::Broadcastable::TestHelper</tt> via +setup+/+teardown+ hooks.
5
5
  #
6
6
  # When installed, all calls to <tt>DurableStreams.broadcast_event_to</tt> and <tt>DurableStreams.broadcast_to</tt>
7
7
  # are recorded in memory instead of being sent to the stream server. Captured messages can be inspected via
8
8
  # <tt>messages_for</tt>.
9
- module DurableStreams::Testing
9
+ module DurableStreams::Rails::Testing
10
10
  thread_mattr_accessor :messages
11
11
 
12
12
  class << self
@@ -0,0 +1,5 @@
1
+ module DurableStreams
2
+ module Rails
3
+ VERSION = "0.3.0"
4
+ end
5
+ end
@@ -1,14 +1,14 @@
1
1
  require "durable_streams"
2
- require "durable_streams/stream_name"
3
- require "durable_streams/broadcasts"
4
- require "durable_streams/testing"
5
- require "durable_streams/engine"
2
+ require "durable_streams/rails/stream_name"
3
+ require "durable_streams/rails/broadcasts"
4
+ require "durable_streams/rails/testing"
5
+ require "durable_streams/rails/engine"
6
6
  require "active_support/core_ext/module/attribute_accessors_per_thread"
7
7
 
8
8
  module DurableStreams
9
9
  extend ActiveSupport::Autoload
10
- extend DurableStreams::StreamName
11
- extend DurableStreams::Broadcasts
10
+ extend DurableStreams::Rails::StreamName
11
+ extend DurableStreams::Rails::Broadcasts
12
12
 
13
13
  mattr_accessor :base_url
14
14
  mattr_accessor :draw_routes, default: true
@@ -55,7 +55,7 @@ module DurableStreams
55
55
  # Idempotent — the server returns 200 when the stream already exists with matching config.
56
56
  # Skipped during tests (no server) and when base_url is not configured.
57
57
  def ensure_stream_exists(stream_name)
58
- return if DurableStreams::Testing.recording?
58
+ return if DurableStreams::Rails::Testing.recording?
59
59
 
60
60
  stream(stream_name).create_stream(content_type: "application/json")
61
61
  rescue DurableStreams::StreamExistsError
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: durable_streams-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tokimonki
@@ -48,18 +48,18 @@ files:
48
48
  - MIT-LICENSE
49
49
  - README.md
50
50
  - Rakefile
51
- - app/controllers/durable_streams/auth_controller.rb
52
- - app/jobs/durable_streams/broadcast_job.rb
53
- - app/models/concerns/durable_streams/broadcastable.rb
51
+ - app/controllers/durable_streams/rails/auth_controller.rb
52
+ - app/jobs/durable_streams/rails/broadcast_job.rb
53
+ - app/models/concerns/durable_streams/rails/broadcastable.rb
54
54
  - config/routes.rb
55
55
  - lib/durable_streams-rails.rb
56
- - lib/durable_streams/broadcastable/test_helper.rb
57
- - lib/durable_streams/broadcasts.rb
58
- - lib/durable_streams/engine.rb
59
- - lib/durable_streams/server_config.rb
60
- - lib/durable_streams/stream_name.rb
61
- - lib/durable_streams/testing.rb
62
- - lib/durable_streams/version.rb
56
+ - lib/durable_streams/rails/broadcastable/test_helper.rb
57
+ - lib/durable_streams/rails/broadcasts.rb
58
+ - lib/durable_streams/rails/engine.rb
59
+ - lib/durable_streams/rails/server_config.rb
60
+ - lib/durable_streams/rails/stream_name.rb
61
+ - lib/durable_streams/rails/testing.rb
62
+ - lib/durable_streams/rails/version.rb
63
63
  - lib/generators/durable_streams/install/USAGE
64
64
  - lib/generators/durable_streams/install/install_generator.rb
65
65
  - lib/generators/durable_streams/install/templates/bin/durable-streams.tt
@@ -1,25 +0,0 @@
1
- module DurableStreams
2
- class AuthController < ActionController::API
3
- def verify
4
- if request.headers["X-Forwarded-Uri"]
5
- stream_name ? head(:ok) : head(:unauthorized)
6
- elsif valid_server_api_key?
7
- head :ok
8
- else
9
- head :unauthorized
10
- end
11
- end
12
-
13
- private
14
- def stream_name
15
- DurableStreams.verify_signed_url(request.headers["X-Forwarded-Uri"])
16
- end
17
-
18
- def valid_server_api_key?
19
- if auth_header = request.headers["Authorization"]
20
- auth_header.start_with?("Bearer ") &&
21
- ActiveSupport::SecurityUtils.secure_compare(auth_header.delete_prefix("Bearer "), DurableStreams.server_api_key)
22
- end
23
- end
24
- end
25
- end
@@ -1,9 +0,0 @@
1
- # The job that powers all the <tt>stream_*_later_to</tt> broadcasts available in <tt>DurableStreams::Broadcastable</tt>.
2
- class DurableStreams::BroadcastJob < ActiveJob::Base
3
- discard_on ActiveJob::DeserializationError
4
- retry_on DurableStreams::ConnectionError, wait: :polynomially_longer, attempts: 10
5
-
6
- def perform(stream_name, type:, key:, value:, operation:)
7
- DurableStreams.broadcast_event_to stream_name, type: type, key: key, value: value, operation: operation
8
- end
9
- end
@@ -1,157 +0,0 @@
1
- module DurableStreams
2
- module Broadcastable
3
- module TestHelper
4
- extend ActiveSupport::Concern
5
-
6
- included do
7
- include DurableStreams::StreamName
8
-
9
- setup { DurableStreams::Testing.install! }
10
- teardown { DurableStreams::Testing.reset! }
11
- end
12
-
13
- # Asserts that State Protocol events were broadcast to a stream
14
- #
15
- # ==== Arguments
16
- #
17
- # * <tt>stream_name_or_object</tt> the objects used to generate the
18
- # stream name, or the name itself
19
- # * <tt>&block</tt> optional block executed before the
20
- # assertion
21
- #
22
- # ==== Options
23
- #
24
- # * <tt>count:</tt> the number of events that are expected to be broadcast
25
- #
26
- # Asserts events were broadcast:
27
- #
28
- # comment = Comment.find(1)
29
- # comment.stream_insert_to comment.post
30
- #
31
- # assert_stream_broadcasts comment.post
32
- #
33
- # Asserts that two events were broadcast:
34
- #
35
- # comment = Comment.find(1)
36
- # comment.stream_insert_to comment.post
37
- # comment.stream_update_to comment.post
38
- #
39
- # assert_stream_broadcasts comment.post, count: 2
40
- #
41
- # You can pass a block to run before the assertion:
42
- #
43
- # comment = Comment.find(1)
44
- #
45
- # assert_stream_broadcasts comment.post do
46
- # comment.stream_insert_to comment.post
47
- # end
48
- #
49
- # In addition to a String, the helper also accepts an Object or Array to
50
- # determine the stream name:
51
- #
52
- # post = Post.find(1)
53
- #
54
- # assert_stream_broadcasts post do
55
- # post.comments.create!(body: "Hello")
56
- # end
57
- #
58
- def assert_stream_broadcasts(stream_name_or_object, count: nil, &block)
59
- payloads = capture_stream_broadcasts(stream_name_or_object, &block)
60
- stream_name = stream_name_from(stream_name_or_object)
61
-
62
- if count.nil?
63
- assert_not_empty payloads, "Expected at least one broadcast on #{stream_name.inspect}, but there were none"
64
- else
65
- broadcasts = "Durable Stream broadcast".pluralize(count)
66
-
67
- assert count == payloads.count, "Expected #{count} #{broadcasts} on #{stream_name.inspect}, but there were #{payloads.count}"
68
- end
69
- end
70
-
71
- # Asserts that no State Protocol events were broadcast to a stream
72
- #
73
- # ==== Arguments
74
- #
75
- # * <tt>stream_name_or_object</tt> the objects used to generate the
76
- # stream name, or the name itself
77
- # * <tt>&block</tt> optional block executed before the
78
- # assertion
79
- #
80
- # Asserts that no events were broadcast:
81
- #
82
- # assert_no_stream_broadcasts "messages" do
83
- # # do something other than broadcast to "messages"
84
- # end
85
- #
86
- # In addition to a String, the helper also accepts an Object or Array to
87
- # determine the stream name:
88
- #
89
- # post = Post.find(1)
90
- #
91
- # assert_no_stream_broadcasts post do
92
- # # do something other than broadcast to post's stream
93
- # end
94
- #
95
- def assert_no_stream_broadcasts(stream_name_or_object, &block)
96
- block&.call
97
-
98
- stream_name = stream_name_from(stream_name_or_object)
99
- payloads = stream_broadcasts_for(stream_name)
100
-
101
- assert payloads.empty?, "Expected no broadcasts on #{stream_name.inspect}, but there were #{payloads.count}"
102
- end
103
-
104
- # Captures any State Protocol events that were broadcast to a stream
105
- #
106
- # ==== Arguments
107
- #
108
- # * <tt>stream_name_or_object</tt> the objects used to generate the
109
- # stream name, or the name itself
110
- # * <tt>&block</tt> optional block to capture broadcasts during execution
111
- #
112
- # Returns any events that have been broadcast as an Array of parsed JSON hashes
113
- #
114
- # comment = Comment.find(1)
115
- # comment.stream_insert_to comment.post
116
- # comment.stream_update_to comment.post
117
- #
118
- # events = capture_stream_broadcasts comment.post
119
- #
120
- # assert_equal "insert", events.first["headers"]["operation"]
121
- # assert_equal "update", events.second["headers"]["operation"]
122
- #
123
- # You can pass a block to limit the scope of the broadcasts being captured:
124
- #
125
- # comment = Comment.find(1)
126
- #
127
- # events = capture_stream_broadcasts comment.post do
128
- # comment.stream_insert_to comment.post
129
- # end
130
- #
131
- # assert_equal "insert", events.first["headers"]["operation"]
132
- #
133
- def capture_stream_broadcasts(stream_name_or_object, &block)
134
- stream_name = stream_name_from(stream_name_or_object)
135
-
136
- if block_given?
137
- before = stream_broadcasts_for(stream_name).size
138
- block.call
139
- stream_broadcasts_for(stream_name)[before..]
140
- else
141
- stream_broadcasts_for(stream_name)
142
- end
143
- end
144
-
145
- private
146
- def stream_broadcasts_for(stream_name)
147
- DurableStreams::Testing.messages_for(stream_name).map do |message|
148
- if message.is_a?(String)
149
- JSON.parse(message)
150
- else
151
- message
152
- end
153
- end
154
- end
155
- end
156
- end
157
- end
@@ -1,70 +0,0 @@
1
- require "rails/engine"
2
-
3
- module DurableStreams
4
- class Engine < Rails::Engine
5
- isolate_namespace DurableStreams
6
- config.eager_load_namespaces << DurableStreams
7
- config.durable_streams = ActiveSupport::OrderedOptions.new
8
- config.autoload_once_paths = %W(
9
- #{root}/app/controllers
10
- #{root}/app/models
11
- #{root}/app/models/concerns
12
- #{root}/app/jobs
13
- )
14
-
15
- # If the parent application does not use Active Job, app/jobs cannot
16
- # be eager loaded, because it references the ActiveJob constant.
17
- initializer "durable_streams.no_active_job", before: :set_eager_load_paths do
18
- unless defined?(ActiveJob)
19
- Rails.autoloaders.once.do_not_eager_load("#{root}/app/jobs")
20
- end
21
- end
22
-
23
- initializer "durable_streams.broadcastable" do
24
- ActiveSupport.on_load(:active_record) do
25
- if defined?(ActiveJob)
26
- include DurableStreams::Broadcastable
27
- end
28
- end
29
- end
30
-
31
- initializer "durable_streams.configs" do
32
- config.after_initialize do |app|
33
- DurableStreams.draw_routes = app.config.durable_streams.draw_routes != false
34
- end
35
- end
36
-
37
- initializer "durable_streams.signed_stream_verifier_key" do
38
- config.after_initialize do
39
- DurableStreams.signed_stream_verifier_key =
40
- config.durable_streams.signed_stream_verifier_key ||
41
- Rails.application.key_generator.generate_key("durable_streams/signed_stream_verifier_key")
42
- end
43
- end
44
-
45
- initializer "durable_streams.server_api_key" do
46
- config.after_initialize do
47
- DurableStreams.server_api_key =
48
- config.durable_streams.server_api_key ||
49
- Rails.application.key_generator.generate_key("durable_streams/server_api_key").unpack1("H*")
50
-
51
- if DurableStreams.base_url.present?
52
- DurableStreams.configure do |c|
53
- c.base_url = DurableStreams.base_url
54
- c.default_headers = { "Authorization" => "Bearer #{DurableStreams.server_api_key}" }
55
- end
56
- end
57
- end
58
- end
59
-
60
- # No Action Cable dependency -- load test helpers directly.
61
- initializer "durable_streams.test_assertions" do
62
- ActiveSupport.on_load(:active_support_test_case) do
63
- if defined?(ActiveJob)
64
- require "durable_streams/broadcastable/test_helper"
65
- include DurableStreams::Broadcastable::TestHelper
66
- end
67
- end
68
- end
69
- end
70
- end
@@ -1,62 +0,0 @@
1
- require "yaml"
2
- require "erb"
3
-
4
- module DurableStreams
5
- class ServerConfig
6
- TEMPLATE = ERB.new(<<~'CADDYFILE', trim_mode: "-")
7
- {
8
- admin off
9
- <%- unless @config["domain"] -%>
10
- auto_https off
11
- <%- end -%>
12
- }
13
-
14
- <%= address %> {
15
- route <%= route_path %> {
16
- forward_auth <%= auth["url"] %> {
17
- uri <%= auth["path"] %>
18
- <%- copy_headers.each do |header| -%>
19
- copy_headers <%= header %>
20
- <%- end -%>
21
- }
22
- durable_streams<%= storage_block %>
23
- }
24
- }
25
- CADDYFILE
26
-
27
- def self.generate(config_path, environment)
28
- new(config_path, environment).generate
29
- end
30
-
31
- def initialize(config_path, environment)
32
- @config = YAML.load_file(config_path, aliases: true).fetch(environment)
33
- end
34
-
35
- def generate
36
- TEMPLATE.result(binding)
37
- end
38
-
39
- private
40
- def address
41
- @config["domain"] || ":#{@config.fetch("port", 4437)}"
42
- end
43
-
44
- def route_path
45
- @config.fetch("route", "/v1/streams/*")
46
- end
47
-
48
- def auth
49
- @config.fetch("auth")
50
- end
51
-
52
- def copy_headers
53
- auth.fetch("copy_headers", [ "Cookie" ])
54
- end
55
-
56
- def storage_block
57
- if dir = @config.dig("storage", "data_dir")
58
- " {\n\t\t\tdata_dir #{dir}\n\t\t}"
59
- end
60
- end
61
- end
62
- end
@@ -1,3 +0,0 @@
1
- module DurableStreams
2
- RAILS_VERSION = "0.2.5"
3
- end