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 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,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -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,4 @@
1
+ AsyncapiCable::Engine.routes.draw do
2
+ get "schemas", to: "schemas#index", as: :schemas
3
+ get "schemas/*id", to: "schemas#show", as: :schema
4
+ end
@@ -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