AsyncapiCable
AsyncAPI 3 documentation and runtime payload validation for Rails ActionCable channels. Pairs with openapi-ruby: cable-only schemas share the same OpenapiRuby::Components::Base registry as REST schemas, so a single JSON Schema 2020-12 component can flow into both the OpenAPI document and the AsyncAPI document.
Why
OpenAPI 3.1 still has no native WebSocket support. AsyncAPI 3 does, and uses JSON Schema 2020-12 by default — the same schema dialect openapi-ruby already produces. AsyncapiCable bridges the two: declare a channel via a familiar RSpec/Minitest DSL, point it at a component class, and you get both a publishable AsyncAPI 3 document and an in-process broadcast validator from the same source of truth.
Quick start
Add the gem to the host Gemfile:
gem "asyncapi_cable"
Configure one or more cable documents in an initializer. Each entry is one AsyncAPI document and one component_scope used to filter OpenapiRuby::Components::Base subclasses:
# config/initializers/asyncapi_cable.rb
AsyncapiCable.configure do |config|
config.schemas = {
cable_internal: {
info: { title: "My Internal Cable API", version: "v1" },
servers: {
dev: { host: "localhost:3000", pathname: "/cable", protocol: "ws" },
live: { host: "app.example.com", pathname: "/cable", protocol: "wss" }
},
component_scope: :cable_internal
}
}
config.schema_output_dir = "asyncapi"
config.validation_mode = :disabled # :disabled | :warn_only | :enabled
end
Declare a message component using the standard OpenapiRuby::Components::Base, scoped to the cable audience:
# packs/api_internal/app/components/internal/v1/schemas/job_status_message.rb
class Internal::V1::Schemas::JobStatusMessage
include OpenapiRuby::Components::Base
component_scopes :cable_internal
schema({
type: :object,
properties: {
action: { type: :string },
user_id: { type: :integer },
status: { type: :string }
},
required: %w[action user_id status]
})
end
Document a channel with the DSL adapter for your test framework. The DSL
mirrors openapi-ruby's Minitest-style surface: a flat class-level channel
declaration (no nested example groups), plus plain tests that call
assert_asyncapi_broadcast — which runs your triggering code, captures every
broadcast on the resolved stream, and validates each payload against the
declared message schemas:
# RSpec — spec/asyncapi/job_status_channel_spec.rb
require "asyncapi_cable/rspec"
RSpec.describe JobStatusChannel, type: :asyncapi do
asyncapi_schema :cable_internal
channel "{user_id}-job-status" do
parameter :user_id, schema: { type: :integer }
broadcast "Job status updates" do
operationId "receiveJobStatus"
Internal::V1::Schemas::JobStatusMessage
end
end
it "broadcasts a schema-valid payload" do
payloads = assert_asyncapi_broadcast(params: { user_id: user.id }) do
SomeJob.perform_now(user)
end
expect(payloads.first["action"]).to eq("started")
end
end
# Minitest — test/asyncapi/job_status_channel_test.rb
require "asyncapi_cable/minitest"
class JobStatusChannelTest < ActiveSupport::TestCase
include AsyncapiCable::Adapters::Minitest::DSL
asyncapi_schema :cable_internal
channel "{user_id}-job-status", channel_class: JobStatusChannel do
parameter :user_id, schema: { type: :integer }
broadcast "Job status updates" do
operationId "receiveJobStatus"
Internal::V1::Schemas::JobStatusMessage
end
end
test "broadcasts a schema-valid payload" do
payloads = assert_asyncapi_broadcast(params: { user_id: user.id }) do
SomeJob.perform_now(user)
end
assert_equal "started", payloads.first["action"]
end
end
assert_asyncapi_broadcast needs the ActionCable test adapter (adapter: test
in config/cable.yml). It fails the test when no broadcast arrives on the
expanded stream or when a captured payload violates every declared message,
and raises AsyncapiCable::Error for usage mistakes — no broadcast operation
declared, or params that don't resolve the stream template. It returns the
decoded payloads for follow-up assertions.
Generate the document:
bundle exec rake asyncapi_cable:generate PATTERN="spec/asyncapi/**/*_spec.rb"
# writes asyncapi/cable_internal.yaml
FRAMEWORK= selects which DSL adapters are installed — rspec, minitest, or
hybrid for a suite holding both during a migration. It defaults to what the
host's directory layout says (spec/rails_helper.rb and/or
test/test_helper.rb present), and PATTERN defaults to that framework's
files. Both are usually worth spelling out in a wrapper script, since
declarations live in a known subdirectory:
FRAMEWORK=hybrid bundle exec rake asyncapi_cable:generate \
PATTERN="spec/asyncapi/**/*_spec.rb, test/asyncapi/**/*_test.rb"
Generation runs in a subprocess with the host's environment set to test. The
declaration files are loaded for their channel blocks and never executed:
openapi-ruby's AutorunSuppressor keeps the at_exit hook that runs a suite
from being registered, and its TestSchemaSuppressor keeps
maintain_test_schema! from demanding a database. Nothing in a document comes
from the database, so generation needs none.
A host test helper can skip its test-time setup during such a run:
# test/test_helper.rb
unless AsyncapiCable.
require "rails/test_help"
end
The subprocess also sets openapi-ruby's OPENAPI_RUBY_GENERATING, so a helper
already guarding on OpenapiRuby.schema_generating? needs no second guard.
Guarding is optional per helper: a helper whose constants are referenced at
declaration-file load time must stay unguarded — narrow PATTERN instead.
PATTERN matching nothing is an error rather than a no-op, so a typo in a glob
can't quietly leave the committed documents untouched.
Runtime validation
When config.validation_mode is not :disabled, the engine prepends a hook into ActionCable::Server::Broadcasting#broadcast that validates each payload against the committed AsyncAPI document (Runtime::ContractRegistry parses and memoizes asyncapi/<schema>.yaml) for any channel whose stream address matches. The specs + generator are the write side of the contract; the runtime only reads the committed artifact — so validation works in every process that broadcasts (Minitest, dev server), not just where the RSpec DSL happened to load. A channel that isn't in the generated doc is invisible to runtime validation until rake asyncapi_cable:generate output is committed.
| Mode | Behaviour |
|---|---|
:disabled (default) |
hook is a no-op; broadcasts pass through untouched |
:warn_only |
mismatches log a warning via Rails.logger (or STDOUT outside Rails); broadcast still delivers |
:enabled |
mismatches raise AsyncapiCable::Error; broadcast does not deliver |
An operation's messages are treated as alternatives per AsyncAPI 3 — a payload satisfying any declared message passes; mismatches surface the closest match's errors only.
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.
Broadcast objects, not serialized strings
ActionCable.server.broadcast encodes what you hand it. Hand it a String that
is already JSON and the wire carries a JSON string literal — the client parses
twice, contentSchema becomes the only honest way to describe the shape, and
validation can say no more than "it is a string".
The pattern is easy to arrive at without choosing it, because the usual way to render a payload returns a String:
# Encodes twice: `to_json` renders, ActionCable escapes the result
WidgetChannel.broadcast_to(user, .to_json)
Two costs worth knowing. Escaping every " as \" inflated a 1.2 KB payload by
12.4%, paid on every message — worst on the high-frequency channels. And the
double encoding is what makes assert_asyncapi_broadcast report value at root is not an object, which reads like a schema problem and is not one; both that
failure and the :warn_only log now name the cause.
If a renderer only returns Strings, parse once on the way out — a jbuilder host
might pair to_jbuilder_json with:
def to_jbuilder_hash(*_args)
JSON.parse(to_jbuilder_json)
end
That parse is cheap next to the render it follows (0.3% of it, measured on the
same payload), and it buys a message schema that describes the object itself:
message ::V1::Schemas::Widgets::Widget rather than a string wrapping one.
A String payload is still the right answer when the transport genuinely carries
an opaque representation — one rendered elsewhere, cached as text, or signed.
That is what contentMediaType and contentSchema are for, and such a message
validates without complaint.
Which components land in a document
A document's entry points are what component_scope selects plus every
message a channel declares, and each entry point brings the transitive closure
of everything it $refs. Scope is not a fence around the document.
The declared messages matter on their own: the most natural way to describe a channel that broadcasts a rendered REST resource is to point straight at the component that already describes it, and that component carries no cable scope.
channel "widgets:{user_gid}", channel_class: WidgetChannel do
broadcast "A widget the user owns changed" do
::V1::Schemas::Widgets::Widget # scope :v1
end
end
That matters as soon as a message describes an embedded payload by pointing at
an existing component — say a presence broadcast whose payload string carries
a rendered REST representation:
payload: {
type: :string,
contentMediaType: "application/json",
contentSchema: {"$ref": "#/components/schemas/WordCloud"}
}
WordCloud is a REST component and carries no cable scope. Including the
message without it would write a pointer that resolves to nothing, and
@asyncapi/parser rejects the whole document ('#/components/schemas/X' does not exist). So the writer follows the reference and brings it along, together
with anything it references in turn. A name that matches no registered
component is left as written — the document then fails to parse, which is the
right outcome for a typo.
Runtime validation resolves components the same way, so a payload that passes
assert_asyncapi_broadcast passes against the committed document too.
Shadowed component names
A component_name is only unique within a scope. openapi-ruby hosts routinely
document a richer admin variant of a public resource under the same name, and
to_openapi_hash never meets the collision because it filters by scope before
indexing by name. A closure walk has no such filter, so it has to say which
variant a pointer meant — picking by registration order would write a document
that parses cleanly and describes the wrong contract.
A $ref means what it means in the referring component's own document, so the
candidate sharing a scope with the referrer wins. Failing that the document's
own scope decides, then openapi-ruby's specificity rule (a scope-specific
component beats a multi-scope one). A name still undecided after all three is a
real ambiguity and raises, naming the candidates:
Ambiguous $ref #/components/schemas/Widget from Cable::V1::Schemas::WidgetMessage:
V1::Schemas::Widgets::Widget [:v1], Admin::V1::Schemas::Widgets::Widget [:admin].
Give the intended component a scope the referrer shares, or name the variants distinctly.
Snake_case wire format
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.
Developing the gem
The gem lives in ruby/ of the asyncapi-cable
repository, alongside the npm generator that turns the documents this gem
writes into typed cable clients.
cd ruby
bundle install
bundle exec rspec
bundle exec standardrb
Specs run against the dummy Rails app in spec/dummy. No Gemfile.lock is
committed (gem convention), so a run resolves against the current gems.
License
MIT.