asyncapi_cable 0.1.0 → 0.2.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: 1cb81ac1d3e0dbf6aec713ae2d67c5132bc969460c1f33ed112139d19282dbda
4
- data.tar.gz: 8718125ac36f7bffbef7e053c9e5e8285e791efc23867618161bcf0ebd2878d2
3
+ metadata.gz: 368fd67f79835ca09ab0f0317cf6b36691ee6f291b86c3cf96c74fc711ad26ef
4
+ data.tar.gz: d29525107905822ec4472586e8ca1d78109692901b8f0349ad73a3b58f949e56
5
5
  SHA512:
6
- metadata.gz: b9a5dc6091faa5faacb0dbbf9485453e3e0feb2131f33a71ca71810e8610e2119125bb2ba85552bfc1cb09eb91dd422904c4e4d0f09391d0b3241d1e7de27fd9
7
- data.tar.gz: 496a763988124507c0c75d49d3f30b884a31a84c18b4c0344ac0525d8e34e8a30c3aa18c17113e915a21d170d822eb83fa63e1e63b95b893d3c4b6b4e213d45a
6
+ metadata.gz: 0573f54362c01f9f880d501aab32101225c586a547d4e443a319b6bd6b078adb6a45f367d03e67a59cb2c2ba6ab9ca8392f2a0555cbd0bbd4aa5f8b0fb03a01c
7
+ data.tar.gz: '08731a2683b02e3831d14149681b732d47ae38d78b8dc4ada624bb565f1cf428a379407e7d171a0575a65ba049073b207cf2729540c25982f2bb9d0eba6c6d24'
data/README.md CHANGED
@@ -176,6 +176,107 @@ An operation's `messages` are treated as alternatives per AsyncAPI 3 — a paylo
176
176
 
177
177
  `assert_asyncapi_broadcast` (see Quick start) validates against the *declared* message classes instead — the write side — so a spec documenting a brand-new channel can prove its payloads before the YAML artifact exists.
178
178
 
179
+ ## Broadcast objects, not serialized strings
180
+
181
+ `ActionCable.server.broadcast` encodes what you hand it. Hand it a String that
182
+ is already JSON and the wire carries a JSON string *literal* — the client parses
183
+ twice, `contentSchema` becomes the only honest way to describe the shape, and
184
+ validation can say no more than "it is a string".
185
+
186
+ The pattern is easy to arrive at without choosing it, because the usual way to
187
+ render a payload returns a String:
188
+
189
+ ```ruby
190
+ # Encodes twice: `to_json` renders, ActionCable escapes the result
191
+ WidgetChannel.broadcast_to(user, widget.to_json)
192
+ ```
193
+
194
+ Two costs worth knowing. Escaping every `"` as `\"` inflated a 1.2 KB payload by
195
+ **12.4%**, paid on every message — worst on the high-frequency channels. And the
196
+ double encoding is what makes `assert_asyncapi_broadcast` report `value at root
197
+ is not an object`, which reads like a schema problem and is not one; both that
198
+ failure and the `:warn_only` log now name the cause.
199
+
200
+ If a renderer only returns Strings, parse once on the way out — a jbuilder host
201
+ might pair `to_jbuilder_json` with:
202
+
203
+ ```ruby
204
+ def to_jbuilder_hash(*_args)
205
+ JSON.parse(to_jbuilder_json)
206
+ end
207
+ ```
208
+
209
+ That parse is cheap next to the render it follows (0.3% of it, measured on the
210
+ same payload), and it buys a message schema that describes the object itself:
211
+ `message ::V1::Schemas::Widgets::Widget` rather than a string wrapping one.
212
+
213
+ A String payload is still the right answer when the transport genuinely carries
214
+ an opaque representation — one rendered elsewhere, cached as text, or signed.
215
+ That is what `contentMediaType` and `contentSchema` are for, and such a message
216
+ validates without complaint.
217
+
218
+ ## Which components land in a document
219
+
220
+ A document's **entry points** are what `component_scope` selects *plus every
221
+ message a channel declares*, and each entry point brings the transitive closure
222
+ of everything it `$ref`s. Scope is not a fence around the document.
223
+
224
+ The declared messages matter on their own: the most natural way to describe a
225
+ channel that broadcasts a rendered REST resource is to point straight at the
226
+ component that already describes it, and that component carries no cable scope.
227
+
228
+ ```ruby
229
+ channel "widgets:{user_gid}", channel_class: WidgetChannel do
230
+ broadcast "A widget the user owns changed" do
231
+ message ::V1::Schemas::Widgets::Widget # scope :v1
232
+ end
233
+ end
234
+ ```
235
+
236
+ That matters as soon as a message describes an embedded payload by pointing at
237
+ an existing component — say a presence broadcast whose `payload` string carries
238
+ a rendered REST representation:
239
+
240
+ ```ruby
241
+ payload: {
242
+ type: :string,
243
+ contentMediaType: "application/json",
244
+ contentSchema: {"$ref": "#/components/schemas/WordCloud"}
245
+ }
246
+ ```
247
+
248
+ `WordCloud` is a REST component and carries no cable scope. Including the
249
+ message without it would write a pointer that resolves to nothing, and
250
+ `@asyncapi/parser` rejects the whole document (`'#/components/schemas/X' does
251
+ not exist`). So the writer follows the reference and brings it along, together
252
+ with anything it references in turn. A name that matches no registered
253
+ component is left as written — the document then fails to parse, which is the
254
+ right outcome for a typo.
255
+
256
+ Runtime validation resolves components the same way, so a payload that passes
257
+ `assert_asyncapi_broadcast` passes against the committed document too.
258
+
259
+ ### Shadowed component names
260
+
261
+ A `component_name` is only unique within a scope. openapi-ruby hosts routinely
262
+ document a richer admin variant of a public resource under the same name, and
263
+ `to_openapi_hash` never meets the collision because it filters by scope before
264
+ indexing by name. A closure walk has no such filter, so it has to say which
265
+ variant a pointer meant — picking by registration order would write a document
266
+ that parses cleanly and describes the wrong contract.
267
+
268
+ A `$ref` means what it means in the referring component's own document, so the
269
+ candidate sharing a scope with the referrer wins. Failing that the document's
270
+ own scope decides, then openapi-ruby's specificity rule (a scope-specific
271
+ component beats a multi-scope one). A name still undecided after all three is a
272
+ real ambiguity and raises, naming the candidates:
273
+
274
+ ```
275
+ Ambiguous $ref #/components/schemas/Widget from Cable::V1::Schemas::WidgetMessage:
276
+ V1::Schemas::Widgets::Widget [:v1], Admin::V1::Schemas::Widgets::Widget [:admin].
277
+ Give the intended component a scope the referrer shares, or name the variants distinctly.
278
+ ```
279
+
179
280
  ## Snake_case wire format
180
281
 
181
282
  The AsyncAPI doc is written from the raw schema definitions, not the camelized `OpenapiRuby::Components::Loader` projection. This is deliberate: ActionCable broadcasts are snake_case in the wild, so the cable document describes the actual wire shape rather than the REST-style camelCase view of the same component. Both the writer and the runtime validator follow the same convention.
@@ -0,0 +1,123 @@
1
+ require "openapi_ruby"
2
+
3
+ module AsyncapiCable
4
+ module Components
5
+ # Expands a set of component classes to everything they `$ref`.
6
+ #
7
+ # Scope alone is not a sufficient filter for a document. A cable message
8
+ # can legitimately reference a component that is not cable-scoped — the
9
+ # presence channels embed a rendered REST representation, so the honest
10
+ # contract says "this is an Item" and points at the REST component. Include
11
+ # the referrer without the referee and the document carries a pointer that
12
+ # resolves to nothing: `@asyncapi/parser` rejects the whole document with
13
+ # `'#/components/schemas/X' does not exist`.
14
+ #
15
+ # So a document includes what its own scope selects, plus the transitive
16
+ # closure of what those components reference. Scope stays the *entry point*
17
+ # into the graph rather than a fence around it.
18
+ module ReferenceClosure
19
+ SCHEMA_REF = %r{\A#/components/schemas/(?<name>\w+)\z}
20
+
21
+ module_function
22
+
23
+ # `scope` is the document's own scope (a symbol, or several in priority
24
+ # order), consulted when the referring component cannot settle a name on
25
+ # its own. The entry points' own scopes follow it: a document assembled
26
+ # from `:v1` components resolves its pointers the way the `:v1` document
27
+ # would, which is what makes the walk order-independent.
28
+ def expand(classes, scope: nil, registry: OpenapiRuby::Components::Registry.instance.all_registered_classes)
29
+ entry_points = classes.to_a
30
+ candidates = registry.group_by(&:component_name)
31
+ preferred = (Array(scope) + entry_points.flat_map(&:_component_scopes)).compact.uniq
32
+ included = {}
33
+ queue = entry_points.dup
34
+
35
+ until queue.empty?
36
+ klass = queue.shift
37
+ name = klass.component_name
38
+ next if included.key?(name)
39
+
40
+ included[name] = klass
41
+ referenced_names(klass._schema_definition).each do |referenced|
42
+ next if included.key?(referenced)
43
+
44
+ referee = resolve(referenced, candidates, referrer: klass, prefer: preferred)
45
+ queue << referee if referee
46
+ end
47
+ end
48
+
49
+ included.values
50
+ end
51
+
52
+ # A component_name is only unique within a scope: openapi-ruby documents
53
+ # routinely carry a richer admin variant of a public resource under the
54
+ # same name, and its own `to_openapi_hash` never meets the collision
55
+ # because it filters by scope before indexing by name. A closure walk has
56
+ # no such filter, so it has to say which variant a pointer meant.
57
+ #
58
+ # A `$ref` means what it means in the referring component's own document,
59
+ # so a candidate sharing a scope with the referrer wins. A shared
60
+ # component settles nothing on its own though — one carrying
61
+ # `[:v1, :admin]` shares a scope with both variants of the name it
62
+ # references — so the document's scopes are applied next, in priority
63
+ # order, then openapi-ruby's specificity rule (scope-specific beats
64
+ # multi-scope). Anything still undecided is a real ambiguity and says so
65
+ # rather than picking by registration order.
66
+ def resolve(name, candidates, referrer:, prefer: [])
67
+ found = candidates[name]
68
+ return nil if found.nil? || found.empty?
69
+ return found.first if found.size == 1
70
+
71
+ narrowed = narrow(found, referrer._component_scopes)
72
+ prefer.each do |scope|
73
+ break if narrowed.size == 1
74
+
75
+ narrowed = narrow(narrowed, [scope])
76
+ end
77
+ narrowed = least_scoped(narrowed) if narrowed.size > 1
78
+ return narrowed.first if narrowed.size == 1
79
+
80
+ raise Error, ambiguity_message(name, referrer, narrowed)
81
+ end
82
+
83
+ def referenced_names(node, found = [])
84
+ case node
85
+ when Hash
86
+ node.each do |key, value|
87
+ match = (key.to_s == "$ref") && value.is_a?(String) && SCHEMA_REF.match(value)
88
+ if match
89
+ found << match[:name]
90
+ else
91
+ referenced_names(value, found)
92
+ end
93
+ end
94
+ when Array
95
+ node.each { |element| referenced_names(element, found) }
96
+ end
97
+
98
+ found.uniq
99
+ end
100
+
101
+ # Narrowing never empties the set: a scope no candidate carries tells us
102
+ # nothing, so it leaves the decision to the next rule.
103
+ def narrow(candidates, scopes)
104
+ wanted = scopes.compact
105
+ return candidates if wanted.empty?
106
+
107
+ matching = candidates.select { |klass| (klass._component_scopes & wanted).any? }
108
+ matching.empty? ? candidates : matching
109
+ end
110
+
111
+ def least_scoped(candidates)
112
+ fewest = candidates.map { |klass| klass._component_scopes.size }.min
113
+ candidates.select { |klass| klass._component_scopes.size == fewest }
114
+ end
115
+
116
+ def ambiguity_message(name, referrer, candidates)
117
+ listed = candidates.map { |klass| "#{klass.name} #{klass._component_scopes.inspect}" }.join(", ")
118
+ "Ambiguous $ref #/components/schemas/#{name} from #{referrer.name}: #{listed}. " \
119
+ "Give the intended component a scope the referrer shares, or name the variants distinctly."
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+
3
+ module AsyncapiCable
4
+ # Explanations for validation failures whose cause is not what the failure
5
+ # says. Only the wording changes — a payload that fails still fails.
6
+ module Diagnostics
7
+ DOUBLE_ENCODED = <<~TEXT.chomp
8
+ The payload is a JSON string rather than an object. A broadcast site
9
+ passing already-serialized JSON (`to_json`, or a renderer that returns a
10
+ String) leaves ActionCable to encode it a second time, so the wire
11
+ carries a JSON string literal and the client has to parse twice.
12
+ Broadcast a Hash, or describe the string with `contentMediaType` and
13
+ `contentSchema`.
14
+ TEXT
15
+
16
+ module_function
17
+
18
+ # A String payload alone proves nothing: `contentSchema` describes an
19
+ # embedded representation on purpose, and such a message validates. This
20
+ # fires only when a schema wanted an object or array at the root and got a
21
+ # string that happens to parse as one — the signature of a payload
22
+ # serialized a layer too early.
23
+ def hint_for(payload, errors)
24
+ return nil unless serialized_container?(payload)
25
+ return nil unless root_container_expected?(errors)
26
+
27
+ DOUBLE_ENCODED
28
+ end
29
+
30
+ def serialized_container?(payload)
31
+ return false unless payload.is_a?(String)
32
+
33
+ parsed = JSON.parse(payload)
34
+ parsed.is_a?(Hash) || parsed.is_a?(Array)
35
+ rescue JSON::ParserError
36
+ false
37
+ end
38
+
39
+ def root_container_expected?(errors)
40
+ errors.any? do |error|
41
+ error["data_pointer"].to_s.empty? && %w[object array].include?(error["type"])
42
+ end
43
+ end
44
+ end
45
+ end
@@ -20,7 +20,8 @@ module AsyncapiCable
20
20
 
21
21
  def build_document(schema_name, schema_config)
22
22
  scope = (schema_config[:component_scope] || schema_config["component_scope"] || :cable).to_sym
23
- components = load_cable_components(scope)
23
+ contexts = Dsl::MetadataStore.contexts_for(schema_name)
24
+ components = load_cable_components(scope, declared: declared_messages(contexts))
24
25
 
25
26
  document = Core::Document.new(
26
27
  info: schema_config[:info] || schema_config["info"] || {},
@@ -28,13 +29,22 @@ module AsyncapiCable
28
29
  cable_components: components
29
30
  )
30
31
 
31
- Dsl::MetadataStore.contexts_for(schema_name).each do |context|
32
+ contexts.each do |context|
32
33
  document.add_channel(context)
33
34
  end
34
35
 
35
36
  document
36
37
  end
37
38
 
39
+ # Every declared message becomes a `components/messages` entry pointing
40
+ # at a schema of the same name, so these are referenced by construction
41
+ # — whatever scope they carry. A contract that reuses an existing REST
42
+ # component as its payload declares one that the document's own scope
43
+ # does not select.
44
+ def declared_messages(contexts)
45
+ contexts.flat_map { |context| context.operations.flat_map(&:messages) }.uniq
46
+ end
47
+
38
48
  # Bypass OpenapiRuby::Components::Loader#to_openapi_hash and read raw
39
49
  # schema definitions directly. The host's openapi-ruby is configured
40
50
  # with `camelize_keys = true` which is correct for REST API docs but
@@ -46,14 +56,21 @@ module AsyncapiCable
46
56
  # classes reachable only via `$ref` strings (e.g. an enum a message
47
57
  # schema refs) aren't autoloaded by Ruby, so a raw registry scan
48
58
  # would miss them. `Loader#load!` is idempotent.
49
- def load_cable_components(scope)
59
+ #
60
+ # Scope and the declared messages select the entry points;
61
+ # ReferenceClosure adds what those components reference, whatever scope
62
+ # the referee carries.
63
+ def load_cable_components(scope, declared: [])
50
64
  OpenapiRuby::Components::Loader.new.load!
51
65
 
52
- schemas = OpenapiRuby::Components::Registry.instance.all_registered_classes.select do |klass|
66
+ scoped = OpenapiRuby::Components::Registry.instance.all_registered_classes.select do |klass|
53
67
  klass._component_scopes.include?(scope)
54
- end.each_with_object({}) do |klass, acc|
55
- acc[klass.component_name] = klass._schema_definition
56
68
  end
69
+ entry_points = (scoped + declared).uniq
70
+ schemas = Components::ReferenceClosure.expand(entry_points, scope: scope)
71
+ .each_with_object({}) do |klass, acc|
72
+ acc[klass.component_name] = klass._schema_definition
73
+ end
57
74
  {"schemas" => schemas}
58
75
  end
59
76
 
@@ -17,7 +17,7 @@ module AsyncapiCable
17
17
  errors = collect_errors(matches, payload)
18
18
  return if errors.empty?
19
19
 
20
- report(mode, stream, errors)
20
+ report(mode, stream, errors, payload)
21
21
  end
22
22
 
23
23
  # AsyncAPI 3 treats a channel's messages as alternatives: a payload
@@ -38,9 +38,12 @@ module AsyncapiCable
38
38
  results.min_by(&:size)
39
39
  end
40
40
 
41
- def self.report(mode, stream, errors)
41
+ def self.report(mode, stream, errors, payload = nil)
42
42
  summary = errors.map { |e| e["error"] }.compact.uniq.join("; ")
43
43
  message = "AsyncAPI broadcast validation failed for stream #{stream.inspect}: #{summary}"
44
+ if (hint = Diagnostics.hint_for(payload, errors))
45
+ message = "#{message}\n#{hint}"
46
+ end
44
47
 
45
48
  case mode
46
49
  when :warn_only
@@ -27,27 +27,42 @@ module AsyncapiCable
27
27
  next if errors.empty?
28
28
 
29
29
  asyncapi_flunk(
30
- "AsyncAPI broadcast validation failed for #{stream.inspect}:\n #{asyncapi_error_summary(errors)}"
30
+ [
31
+ "AsyncAPI broadcast validation failed for #{stream.inspect}:",
32
+ " #{asyncapi_error_summary(errors)}",
33
+ Diagnostics.hint_for(payload, errors)
34
+ ].compact.join("\n")
31
35
  )
32
36
  end
33
37
 
34
38
  payloads
35
39
  end
36
40
 
37
- # Declared message schemas may `$ref` sibling components (enums etc.),
38
- # so the validation document carries every registry class sharing the
39
- # messages' scopes the same set AsyncapiWriter publishes for those
40
- # scopes. Loader#load! eager-loads classes reachable only via `$ref`
41
- # strings and is idempotent. Memoized per scope set: PayloadValidator
42
- # caches one compiled schema per components object.
41
+ # Declared message schemas may `$ref` sibling components an enum, or a
42
+ # REST component an embedded payload points at so the validation
43
+ # document carries every registry class sharing the messages' scopes
44
+ # plus the transitive closure of what those reference. The message
45
+ # classes are entry points in their own right, as they are for the
46
+ # writer: one may well carry a scope no other component shares. That is
47
+ # the same set AsyncapiWriter publishes, so a payload that validates here
48
+ # validates against the committed document too. Memoized per scope set:
49
+ # PayloadValidator caches one compiled schema per components object.
50
+ #
51
+ # `Loader#load!` runs first because it is what assigns inferred scopes:
52
+ # reading `_component_scopes` before it has run yields an empty scope set
53
+ # for every class, and with it an empty components object. It eager-loads
54
+ # classes reachable only via `$ref` strings and is idempotent.
43
55
  def self.components_for(message_classes)
56
+ OpenapiRuby::Components::Loader.new.load!
57
+
44
58
  scopes = message_classes.flat_map(&:_component_scopes).uniq.sort
45
59
  @components ||= {}
46
60
  @components[scopes] ||= begin
47
- OpenapiRuby::Components::Loader.new.load!
48
- schemas = OpenapiRuby::Components::Registry.instance.all_registered_classes.select { |klass|
61
+ scoped = OpenapiRuby::Components::Registry.instance.all_registered_classes.select { |klass|
49
62
  (klass._component_scopes & scopes).any?
50
- }.to_h { |klass| [klass.component_name, klass._schema_definition] }
63
+ }
64
+ schemas = Components::ReferenceClosure.expand((scoped + message_classes).uniq, scope: scopes)
65
+ .to_h { |klass| [klass.component_name, klass._schema_definition] }
51
66
  {"schemas" => schemas}
52
67
  end
53
68
  end
@@ -1,3 +1,3 @@
1
1
  module AsyncapiCable
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,5 +1,7 @@
1
1
  require "asyncapi_cable/version"
2
2
  require "asyncapi_cable/configuration"
3
+ require "asyncapi_cable/diagnostics"
4
+ require "asyncapi_cable/components/reference_closure"
3
5
  require "asyncapi_cable/dsl/metadata_store"
4
6
  require "asyncapi_cable/dsl/operation_context"
5
7
  require "asyncapi_cable/dsl/channel_context"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asyncapi_cable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fobizz
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -99,8 +99,10 @@ files:
99
99
  - lib/asyncapi_cable.rb
100
100
  - lib/asyncapi_cable/adapters/minitest.rb
101
101
  - lib/asyncapi_cable/adapters/rspec.rb
102
+ - lib/asyncapi_cable/components/reference_closure.rb
102
103
  - lib/asyncapi_cable/configuration.rb
103
104
  - lib/asyncapi_cable/core/document.rb
105
+ - lib/asyncapi_cable/diagnostics.rb
104
106
  - lib/asyncapi_cable/dsl/channel_context.rb
105
107
  - lib/asyncapi_cable/dsl/metadata_store.rb
106
108
  - lib/asyncapi_cable/dsl/operation_context.rb