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 +4 -4
- data/README.md +101 -0
- data/lib/asyncapi_cable/components/reference_closure.rb +123 -0
- data/lib/asyncapi_cable/diagnostics.rb +45 -0
- data/lib/asyncapi_cable/generator/asyncapi_writer.rb +23 -6
- data/lib/asyncapi_cable/runtime/channel_hook.rb +5 -2
- data/lib/asyncapi_cable/testing/assert_helpers.rb +25 -10
- data/lib/asyncapi_cable/version.rb +1 -1
- data/lib/asyncapi_cable.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 368fd67f79835ca09ab0f0317cf6b36691ee6f291b86c3cf96c74fc711ad26ef
|
|
4
|
+
data.tar.gz: d29525107905822ec4472586e8ca1d78109692901b8f0349ad73a3b58f949e56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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::
|
|
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
|
-
}
|
|
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
|
data/lib/asyncapi_cable.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|