asyncapi_cable 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +201 -0
- data/Rakefile +3 -0
- data/app/controllers/asyncapi_cable/schemas_controller.rb +29 -0
- data/config/routes.rb +4 -0
- data/lib/asyncapi_cable/adapters/minitest.rb +53 -0
- data/lib/asyncapi_cable/adapters/rspec.rb +58 -0
- data/lib/asyncapi_cable/configuration.rb +22 -0
- data/lib/asyncapi_cable/core/document.rb +137 -0
- data/lib/asyncapi_cable/dsl/channel_context.rb +48 -0
- data/lib/asyncapi_cable/dsl/metadata_store.rb +46 -0
- data/lib/asyncapi_cable/dsl/operation_context.rb +32 -0
- data/lib/asyncapi_cable/engine.rb +18 -0
- data/lib/asyncapi_cable/generator/asyncapi_writer.rb +71 -0
- data/lib/asyncapi_cable/generator/declaration_loader.rb +97 -0
- data/lib/asyncapi_cable/generator/rake_task_support.rb +52 -0
- data/lib/asyncapi_cable/generator/runner.rb +27 -0
- data/lib/asyncapi_cable/minitest.rb +4 -0
- data/lib/asyncapi_cable/rake_tasks.rb +39 -0
- data/lib/asyncapi_cable/rspec.rb +4 -0
- data/lib/asyncapi_cable/runtime/channel_hook.rb +61 -0
- data/lib/asyncapi_cable/runtime/contract_registry.rb +129 -0
- data/lib/asyncapi_cable/runtime/payload_validator.rb +48 -0
- data/lib/asyncapi_cable/runtime/stream_matcher.rb +24 -0
- data/lib/asyncapi_cable/testing/assert_helpers.rb +137 -0
- data/lib/asyncapi_cable/version.rb +3 -0
- data/lib/asyncapi_cable.rb +47 -0
- data/lib/tasks/asyncapi_cable_tasks.rake +1 -0
- metadata +150 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1cb81ac1d3e0dbf6aec713ae2d67c5132bc969460c1f33ed112139d19282dbda
|
|
4
|
+
data.tar.gz: 8718125ac36f7bffbef7e053c9e5e8285e791efc23867618161bcf0ebd2878d2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b9a5dc6091faa5faacb0dbbf9485453e3e0feb2131f33a71ca71810e8610e2119125bb2ba85552bfc1cb09eb91dd422904c4e4d0f09391d0b3241d1e7de27fd9
|
|
7
|
+
data.tar.gz: 496a763988124507c0c75d49d3f30b884a31a84c18b4c0344ac0525d8e34e8a30c3aa18c17113e915a21d170d822eb83fa63e1e63b95b893d3c4b6b4e213d45a
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Fobizz / 101skills GmbH
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# AsyncapiCable
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
Add the gem to the host Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "asyncapi_cable"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
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:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# config/initializers/asyncapi_cable.rb
|
|
21
|
+
AsyncapiCable.configure do |config|
|
|
22
|
+
config.schemas = {
|
|
23
|
+
cable_internal: {
|
|
24
|
+
info: { title: "My Internal Cable API", version: "v1" },
|
|
25
|
+
servers: {
|
|
26
|
+
dev: { host: "localhost:3000", pathname: "/cable", protocol: "ws" },
|
|
27
|
+
live: { host: "app.example.com", pathname: "/cable", protocol: "wss" }
|
|
28
|
+
},
|
|
29
|
+
component_scope: :cable_internal
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
config.schema_output_dir = "asyncapi"
|
|
33
|
+
config.validation_mode = :disabled # :disabled | :warn_only | :enabled
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Declare a message component using the standard `OpenapiRuby::Components::Base`, scoped to the cable audience:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# packs/api_internal/app/components/internal/v1/schemas/job_status_message.rb
|
|
41
|
+
class Internal::V1::Schemas::JobStatusMessage
|
|
42
|
+
include OpenapiRuby::Components::Base
|
|
43
|
+
|
|
44
|
+
component_scopes :cable_internal
|
|
45
|
+
|
|
46
|
+
schema({
|
|
47
|
+
type: :object,
|
|
48
|
+
properties: {
|
|
49
|
+
action: { type: :string },
|
|
50
|
+
user_id: { type: :integer },
|
|
51
|
+
status: { type: :string }
|
|
52
|
+
},
|
|
53
|
+
required: %w[action user_id status]
|
|
54
|
+
})
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Document a channel with the DSL adapter for your test framework. The DSL
|
|
59
|
+
mirrors openapi-ruby's Minitest-style surface: a flat class-level `channel`
|
|
60
|
+
declaration (no nested example groups), plus plain tests that call
|
|
61
|
+
`assert_asyncapi_broadcast` — which runs your triggering code, captures every
|
|
62
|
+
broadcast on the resolved stream, and validates each payload against the
|
|
63
|
+
declared message schemas:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# RSpec — spec/asyncapi/job_status_channel_spec.rb
|
|
67
|
+
require "asyncapi_cable/rspec"
|
|
68
|
+
|
|
69
|
+
RSpec.describe JobStatusChannel, type: :asyncapi do
|
|
70
|
+
asyncapi_schema :cable_internal
|
|
71
|
+
|
|
72
|
+
channel "{user_id}-job-status" do
|
|
73
|
+
parameter :user_id, schema: { type: :integer }
|
|
74
|
+
broadcast "Job status updates" do
|
|
75
|
+
operationId "receiveJobStatus"
|
|
76
|
+
message Internal::V1::Schemas::JobStatusMessage
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "broadcasts a schema-valid payload" do
|
|
81
|
+
payloads = assert_asyncapi_broadcast(params: { user_id: user.id }) do
|
|
82
|
+
SomeJob.perform_now(user)
|
|
83
|
+
end
|
|
84
|
+
expect(payloads.first["action"]).to eq("started")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# Minitest — test/asyncapi/job_status_channel_test.rb
|
|
91
|
+
require "asyncapi_cable/minitest"
|
|
92
|
+
|
|
93
|
+
class JobStatusChannelTest < ActiveSupport::TestCase
|
|
94
|
+
include AsyncapiCable::Adapters::Minitest::DSL
|
|
95
|
+
|
|
96
|
+
asyncapi_schema :cable_internal
|
|
97
|
+
|
|
98
|
+
channel "{user_id}-job-status", channel_class: JobStatusChannel do
|
|
99
|
+
parameter :user_id, schema: { type: :integer }
|
|
100
|
+
broadcast "Job status updates" do
|
|
101
|
+
operationId "receiveJobStatus"
|
|
102
|
+
message Internal::V1::Schemas::JobStatusMessage
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
test "broadcasts a schema-valid payload" do
|
|
107
|
+
payloads = assert_asyncapi_broadcast(params: { user_id: user.id }) do
|
|
108
|
+
SomeJob.perform_now(user)
|
|
109
|
+
end
|
|
110
|
+
assert_equal "started", payloads.first["action"]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`assert_asyncapi_broadcast` needs the ActionCable test adapter (`adapter: test`
|
|
116
|
+
in `config/cable.yml`). It fails the test when no broadcast arrives on the
|
|
117
|
+
expanded stream or when a captured payload violates every declared message,
|
|
118
|
+
and raises `AsyncapiCable::Error` for usage mistakes — no broadcast operation
|
|
119
|
+
declared, or `params` that don't resolve the stream template. It returns the
|
|
120
|
+
decoded payloads for follow-up assertions.
|
|
121
|
+
|
|
122
|
+
Generate the document:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
bundle exec rake asyncapi_cable:generate PATTERN="spec/asyncapi/**/*_spec.rb"
|
|
126
|
+
# writes asyncapi/cable_internal.yaml
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`FRAMEWORK=` selects which DSL adapters are installed — `rspec`, `minitest`, or
|
|
130
|
+
`hybrid` for a suite holding both during a migration. It defaults to what the
|
|
131
|
+
host's directory layout says (`spec/rails_helper.rb` and/or
|
|
132
|
+
`test/test_helper.rb` present), and `PATTERN` defaults to that framework's
|
|
133
|
+
files. Both are usually worth spelling out in a wrapper script, since
|
|
134
|
+
declarations live in a known subdirectory:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
FRAMEWORK=hybrid bundle exec rake asyncapi_cable:generate \
|
|
138
|
+
PATTERN="spec/asyncapi/**/*_spec.rb, test/asyncapi/**/*_test.rb"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Generation runs in a subprocess with the host's environment set to `test`. The
|
|
142
|
+
declaration files are loaded for their `channel` blocks and never executed:
|
|
143
|
+
openapi-ruby's `AutorunSuppressor` keeps the `at_exit` hook that runs a suite
|
|
144
|
+
from being registered, and its `TestSchemaSuppressor` keeps
|
|
145
|
+
`maintain_test_schema!` from demanding a database. Nothing in a document comes
|
|
146
|
+
from the database, so generation needs none.
|
|
147
|
+
|
|
148
|
+
A host test helper can skip its test-time setup during such a run:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# test/test_helper.rb
|
|
152
|
+
unless AsyncapiCable.schema_generating?
|
|
153
|
+
require "rails/test_help"
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The subprocess also sets openapi-ruby's `OPENAPI_RUBY_GENERATING`, so a helper
|
|
158
|
+
already guarding on `OpenapiRuby.schema_generating?` needs no second guard.
|
|
159
|
+
Guarding is optional per helper: a helper whose constants are referenced at
|
|
160
|
+
declaration-file *load* time must stay unguarded — narrow `PATTERN` instead.
|
|
161
|
+
|
|
162
|
+
`PATTERN` matching nothing is an error rather than a no-op, so a typo in a glob
|
|
163
|
+
can't quietly leave the committed documents untouched.
|
|
164
|
+
|
|
165
|
+
## Runtime validation
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
| Mode | Behaviour |
|
|
170
|
+
|------|-----------|
|
|
171
|
+
| `:disabled` (default) | hook is a no-op; broadcasts pass through untouched |
|
|
172
|
+
| `:warn_only` | mismatches log a warning via `Rails.logger` (or `STDOUT` outside Rails); broadcast still delivers |
|
|
173
|
+
| `:enabled` | mismatches raise `AsyncapiCable::Error`; broadcast does not deliver |
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
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
|
+
|
|
179
|
+
## Snake_case wire format
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
## Developing the gem
|
|
184
|
+
|
|
185
|
+
The gem lives in `ruby/` of the [asyncapi-cable](https://github.com/openapi-ruby/asyncapi-cable)
|
|
186
|
+
repository, alongside the npm generator that turns the documents this gem
|
|
187
|
+
writes into typed cable clients.
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
cd ruby
|
|
191
|
+
bundle install
|
|
192
|
+
bundle exec rspec
|
|
193
|
+
bundle exec standardrb
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Specs run against the dummy Rails app in `spec/dummy`. No Gemfile.lock is
|
|
197
|
+
committed (gem convention), so a run resolves against the current gems.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncapiCable
|
|
4
|
+
class SchemasController < ActionController::API
|
|
5
|
+
def index
|
|
6
|
+
schemas = AsyncapiCable.configuration.schemas.keys.map(&:to_s)
|
|
7
|
+
render json: {schemas: schemas}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
schema_name = params[:id]
|
|
12
|
+
return head :not_found unless AsyncapiCable.configuration.schemas.key?(schema_name.to_sym)
|
|
13
|
+
|
|
14
|
+
file_path = schema_file_path(schema_name)
|
|
15
|
+
return head :not_found unless File.exist?(file_path)
|
|
16
|
+
|
|
17
|
+
content_type = file_path.end_with?(".json") ? "application/json" : "application/x-yaml"
|
|
18
|
+
render plain: File.read(file_path), content_type: content_type
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def schema_file_path(schema_name)
|
|
24
|
+
config = AsyncapiCable.configuration
|
|
25
|
+
ext = (config.schema_output_format == :json) ? "json" : "yaml"
|
|
26
|
+
Rails.root.join(config.schema_output_dir, "#{schema_name}.#{ext}").to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "asyncapi_cable"
|
|
2
|
+
require "asyncapi_cable/testing/assert_helpers"
|
|
3
|
+
require "active_support/core_ext/class/attribute"
|
|
4
|
+
|
|
5
|
+
module AsyncapiCable
|
|
6
|
+
module Adapters
|
|
7
|
+
module Minitest
|
|
8
|
+
module DSL
|
|
9
|
+
include Testing::AssertHelpers
|
|
10
|
+
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.extend ClassMethods
|
|
13
|
+
base.class_attribute :_asyncapi_schema_name, default: nil
|
|
14
|
+
base.class_attribute :_asyncapi_contexts, default: []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
def asyncapi_schema(name)
|
|
19
|
+
self._asyncapi_schema_name = name.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def channel(stream_template, channel_class: nil, &block)
|
|
23
|
+
context = Dsl::ChannelContext.new(
|
|
24
|
+
stream_template,
|
|
25
|
+
channel_class: channel_class,
|
|
26
|
+
schema_name: _asyncapi_schema_name
|
|
27
|
+
)
|
|
28
|
+
context.instance_eval(&block) if block
|
|
29
|
+
self._asyncapi_contexts = _asyncapi_contexts + [context]
|
|
30
|
+
Dsl::MetadataStore.register(context)
|
|
31
|
+
context
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def asyncapi_declared_contexts
|
|
38
|
+
self.class._asyncapi_contexts
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def asyncapi_flunk(message)
|
|
42
|
+
flunk(message)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.install!
|
|
47
|
+
# Intentional no-op — mirrors openapi-ruby's Minitest adapter.
|
|
48
|
+
# Hosts opt in by including AsyncapiCable::Adapters::Minitest::DSL
|
|
49
|
+
# into their base test class (e.g. ActiveSupport::TestCase).
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "asyncapi_cable"
|
|
2
|
+
require "asyncapi_cable/testing/assert_helpers"
|
|
3
|
+
|
|
4
|
+
module AsyncapiCable
|
|
5
|
+
module Adapters
|
|
6
|
+
module RSpec
|
|
7
|
+
# Class-level DSL extended onto :asyncapi example groups. Mirrors
|
|
8
|
+
# openapi-ruby's Minitest-style `api_path`: `channel` registers the
|
|
9
|
+
# declaration flat and opens no nested example groups; examples are
|
|
10
|
+
# plain `it` blocks calling assert_asyncapi_broadcast.
|
|
11
|
+
module ExampleGroupHelpers
|
|
12
|
+
def asyncapi_schema(name)
|
|
13
|
+
metadata[:asyncapi_schema_name] = name.to_sym
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def channel(stream_template, channel_class: nil, &block)
|
|
17
|
+
context = Dsl::ChannelContext.new(
|
|
18
|
+
stream_template,
|
|
19
|
+
channel_class: channel_class || described_class,
|
|
20
|
+
schema_name: metadata[:asyncapi_schema_name]
|
|
21
|
+
)
|
|
22
|
+
context.instance_eval(&block) if block
|
|
23
|
+
metadata[:asyncapi_contexts] ||= []
|
|
24
|
+
metadata[:asyncapi_contexts] << context
|
|
25
|
+
Dsl::MetadataStore.register(context)
|
|
26
|
+
context
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Instance-level helpers mixed into :asyncapi examples.
|
|
31
|
+
module ExampleHelpers
|
|
32
|
+
include Testing::AssertHelpers
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def asyncapi_declared_contexts
|
|
37
|
+
meta = ::RSpec.current_example.metadata
|
|
38
|
+
while meta
|
|
39
|
+
return meta[:asyncapi_contexts] if meta[:asyncapi_contexts]
|
|
40
|
+
meta = meta[:parent_example_group] || meta[:example_group]
|
|
41
|
+
end
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def asyncapi_flunk(message)
|
|
46
|
+
raise message
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.install!
|
|
51
|
+
::RSpec.configure do |config|
|
|
52
|
+
config.extend ExampleGroupHelpers, type: :asyncapi
|
|
53
|
+
config.include ExampleHelpers, type: :asyncapi
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module AsyncapiCable
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :schemas, :schema_output_dir, :schema_output_format
|
|
4
|
+
attr_reader :validation_mode
|
|
5
|
+
|
|
6
|
+
VALIDATION_MODES = %i[disabled warn_only enabled].freeze
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@schemas = {}
|
|
10
|
+
@validation_mode = :disabled
|
|
11
|
+
@schema_output_dir = "asyncapi"
|
|
12
|
+
@schema_output_format = :yaml
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validation_mode=(mode)
|
|
16
|
+
unless VALIDATION_MODES.include?(mode)
|
|
17
|
+
raise ArgumentError, "validation_mode must be one of #{VALIDATION_MODES.inspect}, got #{mode.inspect}"
|
|
18
|
+
end
|
|
19
|
+
@validation_mode = mode
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module AsyncapiCable
|
|
4
|
+
module Core
|
|
5
|
+
class Document
|
|
6
|
+
ASYNCAPI_VERSION = "3.0.0"
|
|
7
|
+
|
|
8
|
+
attr_reader :data
|
|
9
|
+
|
|
10
|
+
def initialize(info:, servers: {}, cable_components: {})
|
|
11
|
+
@data = {
|
|
12
|
+
"asyncapi" => ASYNCAPI_VERSION,
|
|
13
|
+
"info" => deep_stringify(info),
|
|
14
|
+
"channels" => {},
|
|
15
|
+
"operations" => {},
|
|
16
|
+
"components" => {
|
|
17
|
+
"schemas" => deep_stringify(cable_components["schemas"] || {}),
|
|
18
|
+
"messages" => {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
@data["servers"] = deep_stringify(servers) unless servers.nil? || servers.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_channel(channel_context)
|
|
25
|
+
channel_name = channel_name_for(channel_context)
|
|
26
|
+
channel_entry = {"address" => channel_context.stream_template}
|
|
27
|
+
# The exact Rails channel class name, so a generated client can use it
|
|
28
|
+
# as the ActionCable subscription identifier without re-deriving it.
|
|
29
|
+
if (klass = channel_context.channel_class) && klass.name
|
|
30
|
+
channel_entry["x-actioncable-channel"] = klass.name
|
|
31
|
+
end
|
|
32
|
+
channel_entry["parameters"] = parameters_for(channel_context)
|
|
33
|
+
channel_entry["messages"] = {}
|
|
34
|
+
channel_entry.delete("parameters") if channel_entry["parameters"].empty?
|
|
35
|
+
|
|
36
|
+
channel_context.operations.each do |op|
|
|
37
|
+
next unless op.kind == :broadcast || op.kind == :publish
|
|
38
|
+
|
|
39
|
+
op.messages.each do |component_class|
|
|
40
|
+
message_name = component_class.component_name
|
|
41
|
+
channel_entry["messages"][message_name] = {
|
|
42
|
+
"$ref" => "#/components/messages/#{message_name}"
|
|
43
|
+
}
|
|
44
|
+
add_message_component(component_class)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
add_operation(channel_name, op)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@data["channels"][channel_name] = channel_entry
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
result = @data.dup
|
|
55
|
+
result["channels"] = sort_hash(result["channels"])
|
|
56
|
+
result["operations"] = sort_hash(result["operations"])
|
|
57
|
+
result["components"] = result["components"].transform_values { |v| sort_hash(v) }
|
|
58
|
+
result.delete("components") if result["components"].values.all?(&:empty?)
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_yaml = to_h.to_yaml
|
|
63
|
+
|
|
64
|
+
def to_json(*_args)
|
|
65
|
+
require "json"
|
|
66
|
+
JSON.pretty_generate(to_h)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def channel_name_for(channel_context)
|
|
72
|
+
klass = channel_context.channel_class
|
|
73
|
+
return channel_context.stream_template if klass.nil? || klass.name.nil?
|
|
74
|
+
|
|
75
|
+
klass.name.demodulize.sub(/Channel\z/, "")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# AsyncAPI 3.0 Parameter Objects are string-only: they carry no `schema`,
|
|
79
|
+
# only `enum`/`default`/`examples`/`description`. Map the declared schema's
|
|
80
|
+
# constraints onto those fields and drop the schema itself, otherwise the
|
|
81
|
+
# document fails 3.0 validation.
|
|
82
|
+
def parameters_for(channel_context)
|
|
83
|
+
channel_context.parameters.each_with_object({}) do |param, acc|
|
|
84
|
+
entry = {}
|
|
85
|
+
entry["description"] = param[:description] if param[:description]
|
|
86
|
+
if (schema = param[:schema])
|
|
87
|
+
entry["enum"] = deep_stringify(schema[:enum]) if schema[:enum]
|
|
88
|
+
entry["default"] = schema[:default] if schema.key?(:default)
|
|
89
|
+
entry["examples"] = deep_stringify(schema[:examples]) if schema[:examples]
|
|
90
|
+
end
|
|
91
|
+
# Server-derived params are not passed by the client at subscribe time.
|
|
92
|
+
entry["x-client-supplied"] = false if param[:client_supplied] == false
|
|
93
|
+
acc[param[:name]] = entry
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def add_operation(channel_name, op)
|
|
98
|
+
op_id = op.operation_id || default_operation_id(channel_name, op)
|
|
99
|
+
action = (op.kind == :broadcast) ? "receive" : "send"
|
|
100
|
+
message_refs = op.messages.map do |klass|
|
|
101
|
+
{"$ref" => "#/channels/#{channel_name}/messages/#{klass.component_name}"}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@data["operations"][op_id] = {
|
|
105
|
+
"action" => action,
|
|
106
|
+
"channel" => {"$ref" => "#/channels/#{channel_name}"},
|
|
107
|
+
"messages" => message_refs
|
|
108
|
+
}
|
|
109
|
+
@data["operations"][op_id]["summary"] = op.summary if op.summary
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def default_operation_id(channel_name, op)
|
|
113
|
+
"#{op.kind}#{channel_name}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def add_message_component(component_class)
|
|
117
|
+
name = component_class.component_name
|
|
118
|
+
@data["components"]["messages"][name] ||= {
|
|
119
|
+
"payload" => {"$ref" => "#/components/schemas/#{name}"}
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def deep_stringify(value)
|
|
124
|
+
case value
|
|
125
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
126
|
+
when Array then value.map { |v| deep_stringify(v) }
|
|
127
|
+
when Symbol then value.to_s
|
|
128
|
+
else value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def sort_hash(hash)
|
|
133
|
+
hash.sort.to_h
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module AsyncapiCable
|
|
2
|
+
module Dsl
|
|
3
|
+
class ChannelContext
|
|
4
|
+
attr_reader :stream_template, :parameters, :operations, :channel_class, :schema_name
|
|
5
|
+
|
|
6
|
+
def initialize(stream_template, channel_class: nil, schema_name: nil)
|
|
7
|
+
@stream_template = stream_template
|
|
8
|
+
@channel_class = channel_class
|
|
9
|
+
@schema_name = schema_name
|
|
10
|
+
@parameters = []
|
|
11
|
+
@operations = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# `client_supplied: false` marks an address parameter the server derives
|
|
15
|
+
# (e.g. from the session) rather than one the client passes at subscribe
|
|
16
|
+
# time — the generated client omits it from the channel's params.
|
|
17
|
+
def parameter(name, schema: nil, description: nil, client_supplied: true)
|
|
18
|
+
@parameters << {
|
|
19
|
+
name: name.to_s,
|
|
20
|
+
schema: schema,
|
|
21
|
+
description: description,
|
|
22
|
+
client_supplied: client_supplied
|
|
23
|
+
}.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def subscribe(summary = nil, &block)
|
|
27
|
+
add_operation(:subscribe, summary, &block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def broadcast(summary = nil, &block)
|
|
31
|
+
add_operation(:broadcast, summary, &block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def publish(summary = nil, &block)
|
|
35
|
+
add_operation(:publish, summary, &block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def add_operation(kind, summary, &block)
|
|
41
|
+
op = OperationContext.new(kind, summary)
|
|
42
|
+
op.instance_eval(&block) if block
|
|
43
|
+
@operations << op
|
|
44
|
+
op
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module AsyncapiCable
|
|
2
|
+
module Dsl
|
|
3
|
+
class MetadataStore
|
|
4
|
+
class << self
|
|
5
|
+
def instance
|
|
6
|
+
@instance ||= new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(context) = instance.register(context)
|
|
10
|
+
|
|
11
|
+
def contexts_for(schema_name) = instance.contexts_for(schema_name)
|
|
12
|
+
|
|
13
|
+
def all_contexts = instance.all_contexts
|
|
14
|
+
|
|
15
|
+
def clear!(scope: nil) = instance.clear!(scope: scope)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@contexts = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register(context)
|
|
23
|
+
@contexts << context
|
|
24
|
+
context
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def contexts_for(schema_name)
|
|
28
|
+
schema_name = schema_name&.to_sym
|
|
29
|
+
@contexts.select { |c| c.schema_name.nil? || c.schema_name.to_sym == schema_name }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all_contexts
|
|
33
|
+
@contexts.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear!(scope: nil)
|
|
37
|
+
if scope
|
|
38
|
+
target = scope.to_sym
|
|
39
|
+
@contexts.reject! { |c| c.schema_name && c.schema_name.to_sym == target }
|
|
40
|
+
else
|
|
41
|
+
@contexts = []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module AsyncapiCable
|
|
2
|
+
module Dsl
|
|
3
|
+
class OperationContext
|
|
4
|
+
KINDS = %i[subscribe broadcast publish].freeze
|
|
5
|
+
|
|
6
|
+
attr_reader :kind, :summary, :messages
|
|
7
|
+
attr_accessor :operation_id
|
|
8
|
+
|
|
9
|
+
def initialize(kind, summary = nil)
|
|
10
|
+
unless KINDS.include?(kind)
|
|
11
|
+
raise ArgumentError, "operation kind must be one of #{KINDS.inspect}, got #{kind.inspect}"
|
|
12
|
+
end
|
|
13
|
+
@kind = kind
|
|
14
|
+
@summary = summary
|
|
15
|
+
@messages = []
|
|
16
|
+
@operation_id = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def message(component_class)
|
|
20
|
+
unless component_class.is_a?(Class) && component_class < OpenapiRuby::Components::Base
|
|
21
|
+
raise ArgumentError, "message must be an OpenapiRuby::Components::Base subclass, got #{component_class.inspect}"
|
|
22
|
+
end
|
|
23
|
+
@messages << component_class
|
|
24
|
+
component_class
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def operationId(value)
|
|
28
|
+
@operation_id = value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|