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.
@@ -0,0 +1,137 @@
1
+ require "json"
2
+
3
+ module AsyncapiCable
4
+ module Testing
5
+ # Shared core of assert_asyncapi_broadcast. Adapters mix this in and
6
+ # supply two hooks: #asyncapi_declared_contexts (the channel contexts
7
+ # declared on the test class / example group) and #asyncapi_flunk
8
+ # (framework-native test failure).
9
+ #
10
+ # Usage errors (undeclared broadcast operation, unresolved template
11
+ # params, non-test cable adapter) raise AsyncapiCable::Error; only
12
+ # genuine test failures go through asyncapi_flunk.
13
+ module AssertHelpers
14
+ def assert_asyncapi_broadcast(params: {}, &trigger)
15
+ raise Error, "assert_asyncapi_broadcast requires a block that triggers the broadcast" unless trigger
16
+
17
+ context = asyncapi_find_broadcast_context!(asyncapi_declared_contexts, params)
18
+ stream = asyncapi_expand_stream!(context.stream_template, params)
19
+ payloads = asyncapi_capture_broadcasts(stream, &trigger)
20
+
21
+ if payloads.empty?
22
+ asyncapi_flunk("Expected at least one broadcast on #{stream.inspect}, but none was captured")
23
+ end
24
+
25
+ payloads.each do |payload|
26
+ errors = asyncapi_broadcast_errors(context, payload)
27
+ next if errors.empty?
28
+
29
+ asyncapi_flunk(
30
+ "AsyncAPI broadcast validation failed for #{stream.inspect}:\n #{asyncapi_error_summary(errors)}"
31
+ )
32
+ end
33
+
34
+ payloads
35
+ end
36
+
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.
43
+ def self.components_for(message_classes)
44
+ scopes = message_classes.flat_map(&:_component_scopes).uniq.sort
45
+ @components ||= {}
46
+ @components[scopes] ||= begin
47
+ OpenapiRuby::Components::Loader.new.load!
48
+ schemas = OpenapiRuby::Components::Registry.instance.all_registered_classes.select { |klass|
49
+ (klass._component_scopes & scopes).any?
50
+ }.to_h { |klass| [klass.component_name, klass._schema_definition] }
51
+ {"schemas" => schemas}
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def asyncapi_find_broadcast_context!(contexts, params)
58
+ candidates = contexts.select { |ctx| ctx.operations.any? { |op| op.kind == :broadcast } }
59
+
60
+ if candidates.empty?
61
+ raise Error, "No channel with a broadcast operation is declared in this test class"
62
+ end
63
+ return candidates.first if candidates.size == 1
64
+
65
+ keys = params.keys.map(&:to_s).sort
66
+ scoped = candidates.select { |ctx| asyncapi_template_params(ctx.stream_template).sort == keys }
67
+ return scoped.first if scoped.size == 1
68
+
69
+ raise Error, "Ambiguous channel for params #{params.keys.inspect}: " \
70
+ "#{candidates.size} declared channels have broadcast operations " \
71
+ "(#{candidates.map { |c| c.stream_template.inspect }.join(", ")})"
72
+ end
73
+
74
+ def asyncapi_expand_stream!(template, params)
75
+ missing = []
76
+ stream = template.gsub(/\{(\w+)\}/) do
77
+ name = ::Regexp.last_match(1)
78
+ value = params[name.to_sym] || params[name]
79
+ missing << name if value.nil?
80
+ value.to_s
81
+ end
82
+
83
+ unless missing.empty?
84
+ raise Error, "Missing params #{missing.inspect} to expand stream template #{template.inspect}"
85
+ end
86
+
87
+ stream
88
+ end
89
+
90
+ def asyncapi_capture_broadcasts(stream)
91
+ pubsub = ::ActionCable.server.pubsub
92
+ unless pubsub.respond_to?(:broadcasts)
93
+ raise Error, "assert_asyncapi_broadcast requires the ActionCable test adapter " \
94
+ "(set `adapter: test` for the test environment in config/cable.yml)"
95
+ end
96
+
97
+ seen = pubsub.broadcasts(stream).size
98
+ yield
99
+ pubsub.broadcasts(stream).drop(seen).map { |message| asyncapi_decode(message) }
100
+ end
101
+
102
+ # Validates against the *declared* message classes (the authoring
103
+ # side), not the committed YAML the runtime ContractRegistry reads —
104
+ # when a spec documents a new channel, the YAML doesn't exist yet.
105
+ # Messages are alternatives per AsyncAPI 3: pass if any validates
106
+ # clean, otherwise surface the closest match's errors.
107
+ def asyncapi_broadcast_errors(context, payload)
108
+ message_classes = context.operations
109
+ .select { |op| op.kind == :broadcast }
110
+ .flat_map(&:messages)
111
+ return [] if message_classes.empty?
112
+
113
+ components = AssertHelpers.components_for(message_classes)
114
+ results = message_classes.map do |klass|
115
+ Runtime::PayloadValidator.instance.validate(payload, klass.component_name, components)
116
+ end
117
+ return [] if results.any?(&:empty?)
118
+
119
+ results.min_by(&:size) || []
120
+ end
121
+
122
+ def asyncapi_error_summary(errors)
123
+ errors.map { |e| e["error"] }.compact.uniq.join("\n ")
124
+ end
125
+
126
+ def asyncapi_template_params(template)
127
+ template.scan(/\{(\w+)\}/).flatten
128
+ end
129
+
130
+ def asyncapi_decode(message)
131
+ message.is_a?(String) ? JSON.parse(message) : message
132
+ rescue JSON::ParserError
133
+ message
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,3 @@
1
+ module AsyncapiCable
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,47 @@
1
+ require "asyncapi_cable/version"
2
+ require "asyncapi_cable/configuration"
3
+ require "asyncapi_cable/dsl/metadata_store"
4
+ require "asyncapi_cable/dsl/operation_context"
5
+ require "asyncapi_cable/dsl/channel_context"
6
+ require "asyncapi_cable/core/document"
7
+ require "asyncapi_cable/generator/asyncapi_writer"
8
+ require "asyncapi_cable/runtime/stream_matcher"
9
+ require "asyncapi_cable/runtime/payload_validator"
10
+ require "asyncapi_cable/runtime/contract_registry"
11
+ require "asyncapi_cable/runtime/channel_hook"
12
+ require "asyncapi_cable/engine"
13
+
14
+ module AsyncapiCable
15
+ class Error < StandardError; end
16
+
17
+ class << self
18
+ def configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield configuration
24
+ end
25
+
26
+ def reset_configuration!
27
+ @configuration = Configuration.new
28
+ end
29
+
30
+ # True when the current process was started by `asyncapi_cable:generate`
31
+ # (the rake task sets ASYNCAPI_CABLE_GENERATING=true in the subprocess).
32
+ #
33
+ # Such a run loads declaration files for their `channel` blocks and never
34
+ # executes them, so a host test helper can skip its test-time setup:
35
+ #
36
+ # unless AsyncapiCable.schema_generating?
37
+ # require "rails/test_help"
38
+ # end
39
+ #
40
+ # Cable generation also sets openapi-ruby's OPENAPI_RUBY_GENERATING, so a
41
+ # helper already guarding on `OpenapiRuby.schema_generating?` needs no
42
+ # second guard.
43
+ def schema_generating?
44
+ ENV["ASYNCAPI_CABLE_GENERATING"] == "true"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1 @@
1
+ require "asyncapi_cable/rake_tasks"
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asyncapi_cable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fobizz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actioncable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: json_schemer
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: openapi-ruby
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 4.0.3
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 4.0.3
69
+ - !ruby/object:Gem::Dependency
70
+ name: railties
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '7.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '7.1'
83
+ description: |
84
+ AsyncAPI Cable adds an AsyncAPI 3 DSL on top of Rails ActionCable.
85
+ It generates an AsyncAPI document for your channels, sharing JSON
86
+ Schema components with OpenAPI tooling, and validates broadcast and
87
+ transmit payloads against declared message schemas at runtime.
88
+ email:
89
+ - dev@fobizz.com
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - MIT-LICENSE
95
+ - README.md
96
+ - Rakefile
97
+ - app/controllers/asyncapi_cable/schemas_controller.rb
98
+ - config/routes.rb
99
+ - lib/asyncapi_cable.rb
100
+ - lib/asyncapi_cable/adapters/minitest.rb
101
+ - lib/asyncapi_cable/adapters/rspec.rb
102
+ - lib/asyncapi_cable/configuration.rb
103
+ - lib/asyncapi_cable/core/document.rb
104
+ - lib/asyncapi_cable/dsl/channel_context.rb
105
+ - lib/asyncapi_cable/dsl/metadata_store.rb
106
+ - lib/asyncapi_cable/dsl/operation_context.rb
107
+ - lib/asyncapi_cable/engine.rb
108
+ - lib/asyncapi_cable/generator/asyncapi_writer.rb
109
+ - lib/asyncapi_cable/generator/declaration_loader.rb
110
+ - lib/asyncapi_cable/generator/rake_task_support.rb
111
+ - lib/asyncapi_cable/generator/runner.rb
112
+ - lib/asyncapi_cable/minitest.rb
113
+ - lib/asyncapi_cable/rake_tasks.rb
114
+ - lib/asyncapi_cable/rspec.rb
115
+ - lib/asyncapi_cable/runtime/channel_hook.rb
116
+ - lib/asyncapi_cable/runtime/contract_registry.rb
117
+ - lib/asyncapi_cable/runtime/payload_validator.rb
118
+ - lib/asyncapi_cable/runtime/stream_matcher.rb
119
+ - lib/asyncapi_cable/testing/assert_helpers.rb
120
+ - lib/asyncapi_cable/version.rb
121
+ - lib/tasks/asyncapi_cable_tasks.rake
122
+ homepage: https://github.com/openapi-ruby/asyncapi-cable
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ allowed_push_host: https://rubygems.org
127
+ homepage_uri: https://github.com/openapi-ruby/asyncapi-cable
128
+ source_code_uri: https://github.com/openapi-ruby/asyncapi-cable/tree/main/ruby
129
+ changelog_uri: https://github.com/openapi-ruby/asyncapi-cable/blob/main/ruby/CHANGELOG.md
130
+ rubygems_mfa_required: 'true'
131
+ post_install_message:
132
+ rdoc_options: []
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: 3.3.0
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubygems_version: 3.5.22
147
+ signing_key:
148
+ specification_version: 4
149
+ summary: AsyncAPI 3 documentation and runtime validation for Rails ActionCable channels.
150
+ test_files: []