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,18 @@
1
+ # Required rather than assumed: a generation subprocess requires this gem
2
+ # before the host boots Rails, and the host's routes reference
3
+ # AsyncapiCable::Engine — so skipping the class definition when Rails happens
4
+ # not to be loaded yet leaves it permanently undefined.
5
+ require "rails"
6
+ require "rails/engine"
7
+
8
+ module AsyncapiCable
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace AsyncapiCable
11
+
12
+ config.after_initialize do
13
+ if defined?(ActionCable::Server::Broadcasting)
14
+ ActionCable::Server::Broadcasting.prepend(AsyncapiCable::Runtime::ChannelHook)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ require "fileutils"
2
+
3
+ module AsyncapiCable
4
+ module Generator
5
+ class AsyncapiWriter
6
+ class << self
7
+ def generate_all!(output_dir: nil, format: nil)
8
+ configuration = AsyncapiCable.configuration
9
+ output_dir ||= configuration.schema_output_dir
10
+ format ||= configuration.schema_output_format
11
+
12
+ raise Error, "AsyncapiCable.configuration.schemas is empty" if configuration.schemas.empty?
13
+
14
+ configuration.schemas.map do |schema_name, schema_config|
15
+ document = build_document(schema_name, schema_config)
16
+ path = write(document, schema_name, output_dir: output_dir, format: format)
17
+ [schema_name, path]
18
+ end.to_h
19
+ end
20
+
21
+ def build_document(schema_name, schema_config)
22
+ scope = (schema_config[:component_scope] || schema_config["component_scope"] || :cable).to_sym
23
+ components = load_cable_components(scope)
24
+
25
+ document = Core::Document.new(
26
+ info: schema_config[:info] || schema_config["info"] || {},
27
+ servers: schema_config[:servers] || schema_config["servers"] || {},
28
+ cable_components: components
29
+ )
30
+
31
+ Dsl::MetadataStore.contexts_for(schema_name).each do |context|
32
+ document.add_channel(context)
33
+ end
34
+
35
+ document
36
+ end
37
+
38
+ # Bypass OpenapiRuby::Components::Loader#to_openapi_hash and read raw
39
+ # schema definitions directly. The host's openapi-ruby is configured
40
+ # with `camelize_keys = true` which is correct for REST API docs but
41
+ # wrong for cable AsyncAPI docs whose payloads are the snake_case
42
+ # wire format `BroadcastingConcern` actually emits. The runtime
43
+ # PayloadValidator already does the same bypass for the same reason.
44
+ #
45
+ # We still need the Loader's eager-load side effect though: component
46
+ # classes reachable only via `$ref` strings (e.g. an enum a message
47
+ # schema refs) aren't autoloaded by Ruby, so a raw registry scan
48
+ # would miss them. `Loader#load!` is idempotent.
49
+ def load_cable_components(scope)
50
+ OpenapiRuby::Components::Loader.new.load!
51
+
52
+ schemas = OpenapiRuby::Components::Registry.instance.all_registered_classes.select do |klass|
53
+ klass._component_scopes.include?(scope)
54
+ end.each_with_object({}) do |klass, acc|
55
+ acc[klass.component_name] = klass._schema_definition
56
+ end
57
+ {"schemas" => schemas}
58
+ end
59
+
60
+ def write(document, schema_name, output_dir:, format:)
61
+ ext = (format.to_sym == :json) ? "json" : "yaml"
62
+ path = File.join(output_dir, "#{schema_name}.#{ext}")
63
+ FileUtils.mkdir_p(File.dirname(path))
64
+ contents = (ext == "json") ? document.to_json : document.to_yaml
65
+ File.write(path, contents)
66
+ path
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ require "asyncapi_cable"
2
+ require "openapi_ruby/generator/autorun_suppressor"
3
+ require "openapi_ruby/generator/test_schema_suppressor"
4
+
5
+ module AsyncapiCable
6
+ module Generator
7
+ # Loads the spec/test files that carry `channel` declarations so the
8
+ # MetadataStore holds them. Both the generator and the channel-coverage
9
+ # gate need exactly that, in whichever framework the host writes its
10
+ # declarations.
11
+ #
12
+ # Loading must not *run* the files. Both suppressors come from
13
+ # openapi-ruby rather than being reimplemented here: they work around
14
+ # another library's `at_exit` and schema-check behaviour, and two copies
15
+ # of that would drift.
16
+ module DeclarationLoader
17
+ FRAMEWORKS = %w[rspec minitest hybrid].freeze
18
+
19
+ module_function
20
+
21
+ # The adapter has to be live before a declaration file loads — its class
22
+ # body is what calls the DSL.
23
+ def install_adapters!(framework)
24
+ validate_framework!(framework)
25
+
26
+ if framework != "minitest"
27
+ require "rspec/core"
28
+ require "asyncapi_cable/rspec"
29
+ end
30
+
31
+ require "asyncapi_cable/minitest" if framework != "rspec"
32
+ end
33
+
34
+ def suppress_side_effects!
35
+ OpenapiRuby::Generator::AutorunSuppressor.install!
36
+ OpenapiRuby::Generator::TestSchemaSuppressor.install!
37
+ end
38
+
39
+ # Announced through the environment so a host test helper guarding on
40
+ # either flag skips its test-time setup. In-process callers (the
41
+ # channel-coverage gate) need this as much as the generation subprocess,
42
+ # and it is not restored afterwards: the files loaded under the guard
43
+ # stay loaded, so pretending the run is over would be a lie.
44
+ def announce_declaration_run!
45
+ ENV["ASYNCAPI_CABLE_GENERATING"] ||= "true"
46
+ ENV["OPENAPI_RUBY_GENERATING"] ||= "true"
47
+ end
48
+
49
+ # Returns the files that were loaded, in load order.
50
+ def load!(framework:, pattern:)
51
+ install_adapters!(framework)
52
+ announce_declaration_run!
53
+ suppress_side_effects!
54
+
55
+ globs_for(pattern).flat_map { |dir, glob| load_glob(dir, glob) }
56
+ end
57
+
58
+ # Each glob is loaded with its own framework directory at the head of
59
+ # `$LOAD_PATH`, so the `require "rails_helper"` / `require "test_helper"`
60
+ # at the top of a declaration file resolves to the right helper. Spec
61
+ # globs load before test globs, matching openapi-ruby's hybrid script.
62
+ def globs_for(pattern)
63
+ globs = pattern.to_s.split(",").map(&:strip).reject(&:empty?)
64
+ spec_globs = globs.grep(%r{\bspec/})
65
+ test_globs = globs.grep(%r{\btest/})
66
+
67
+ spec_globs.map { |glob| ["spec", glob] } +
68
+ test_globs.map { |glob| ["test", glob] } +
69
+ (globs - spec_globs - test_globs).map { |glob| [nil, glob] }
70
+ end
71
+
72
+ def load_glob(dir, glob)
73
+ # A glob can legitimately resolve directories (`test/asyncapi/**`);
74
+ # `require` on one raises LoadError.
75
+ files = Dir.glob(glob).select { |file| File.file?(file) }.sort
76
+ return [] if files.empty?
77
+ return files.each { |file| require File.expand_path(file) } if dir.nil?
78
+
79
+ path = File.expand_path(dir)
80
+ added = !$LOAD_PATH.include?(path)
81
+ $LOAD_PATH.unshift(path) if added
82
+ begin
83
+ files.each { |file| require File.expand_path(file) }
84
+ ensure
85
+ $LOAD_PATH.delete(path) if added
86
+ end
87
+ end
88
+
89
+ def validate_framework!(framework)
90
+ return if FRAMEWORKS.include?(framework)
91
+
92
+ raise ArgumentError,
93
+ "Unknown test framework #{framework.inspect}. Expected one of #{FRAMEWORKS.join(", ")}."
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,52 @@
1
+ require "openapi_ruby"
2
+ require "openapi_ruby/generator/rake_task_support"
3
+ require "asyncapi_cable/generator/declaration_loader"
4
+
5
+ module AsyncapiCable
6
+ module Generator
7
+ # Helpers backing the `asyncapi_cable:generate` rake task. Framework
8
+ # detection and the subprocess environment are openapi-ruby's: the two
9
+ # generators run against the same host, and a second copy of that logic
10
+ # would drift from the one the OpenAPI schema is generated with.
11
+ module RakeTaskSupport
12
+ module_function
13
+
14
+ def detect_test_framework
15
+ OpenapiRuby::Generator::RakeTaskSupport.detect_test_framework
16
+ end
17
+
18
+ def default_pattern_for(framework)
19
+ DeclarationLoader.validate_framework!(framework)
20
+
21
+ case framework
22
+ when "rspec" then "spec/**/*_spec.rb"
23
+ when "minitest" then "test/**/*_test.rb"
24
+ when "hybrid" then "spec/**/*_spec.rb,test/**/*_test.rb"
25
+ end
26
+ end
27
+
28
+ # `OPENAPI_RUBY_GENERATING` comes along on purpose: host test helpers
29
+ # guard their test-framework requires with
30
+ # `unless OpenapiRuby.schema_generating?`, and a cable generation run is
31
+ # the same kind of run — files are loaded for their declarations, never
32
+ # executed — so that guard has to fire here too.
33
+ def subprocess_env
34
+ OpenapiRuby::Generator::RakeTaskSupport
35
+ .subprocess_env
36
+ .merge("ASYNCAPI_CABLE_GENERATING" => "true")
37
+ end
38
+
39
+ def generate_script(framework, pattern)
40
+ DeclarationLoader.validate_framework!(framework)
41
+
42
+ <<~RUBY
43
+ require "asyncapi_cable/generator/runner"
44
+ AsyncapiCable::Generator::Runner.call(
45
+ framework: #{framework.inspect},
46
+ pattern: #{pattern.inspect}
47
+ )
48
+ RUBY
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,27 @@
1
+ require "asyncapi_cable"
2
+ require "asyncapi_cable/generator/declaration_loader"
3
+
4
+ module AsyncapiCable
5
+ module Generator
6
+ # The body of the generation subprocess: load the declarations, then write
7
+ # every configured document.
8
+ module Runner
9
+ module_function
10
+
11
+ def call(framework:, pattern:, io: $stdout)
12
+ loaded = DeclarationLoader.load!(framework: framework, pattern: pattern)
13
+
14
+ if loaded.empty?
15
+ # Writing now would replace the committed documents with channel-less
16
+ # ones, and a `git diff` gate over the output would still be clean if
17
+ # the host has no committed documents yet.
18
+ raise Error, "no declaration files matched PATTERN=#{pattern.inspect} (framework: #{framework})"
19
+ end
20
+
21
+ AsyncapiWriter.generate_all!.each do |name, path|
22
+ io.puts "Wrote AsyncAPI document #{name} → #{path}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,4 @@
1
+ require "asyncapi_cable"
2
+ require_relative "adapters/minitest"
3
+
4
+ AsyncapiCable::Adapters::Minitest.install!
@@ -0,0 +1,39 @@
1
+ require "rake"
2
+ require "shellwords"
3
+ require "asyncapi_cable/generator/rake_task_support"
4
+
5
+ module AsyncapiCable
6
+ # The Rails engine picks up lib/tasks/*.rake on its own; other hosts add
7
+ #
8
+ # require "asyncapi_cable/rake_tasks"
9
+ #
10
+ # to their Rakefile. Both routes end up here, so the task is defined once.
11
+ module RakeTasks
12
+ extend Rake::DSL
13
+
14
+ def self.install!
15
+ return if Rake::Task.task_defined?("asyncapi_cable:generate")
16
+
17
+ namespace :asyncapi_cable do
18
+ desc "Generate AsyncAPI 3 documents for ActionCable channels. " \
19
+ "FRAMEWORK=rspec|minitest|hybrid, PATTERN= comma-separated globs of declaration files."
20
+ task :generate do
21
+ support = AsyncapiCable::Generator::RakeTaskSupport
22
+ framework = ENV.fetch("FRAMEWORK") { support.detect_test_framework }.to_s
23
+ pattern = ENV.fetch("PATTERN") { support.default_pattern_for(framework) }
24
+
25
+ # A subprocess so the host boots in its own test environment and the
26
+ # suppressors are installed before anything else is required — same
27
+ # arrangement as `openapi_ruby:generate`.
28
+ script = support.generate_script(framework, pattern)
29
+ command = "bundle exec ruby -e #{Shellwords.escape(script)}"
30
+
31
+ puts "Generating AsyncAPI documents (#{framework})..."
32
+ system(support.subprocess_env, command) || abort("AsyncAPI generation failed")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ AsyncapiCable::RakeTasks.install!
@@ -0,0 +1,4 @@
1
+ require "asyncapi_cable"
2
+ require_relative "adapters/rspec"
3
+
4
+ AsyncapiCable::Adapters::RSpec.install!
@@ -0,0 +1,61 @@
1
+ module AsyncapiCable
2
+ module Runtime
3
+ module ChannelHook
4
+ def broadcast(stream, payload, *args, **kwargs)
5
+ Runtime.validate_broadcast!(stream, payload)
6
+ super
7
+ end
8
+ end
9
+
10
+ def self.validate_broadcast!(stream, payload)
11
+ mode = AsyncapiCable.configuration.validation_mode
12
+ return if mode == :disabled
13
+
14
+ matches = ContractRegistry.broadcast_schemas_for(stream)
15
+ return if matches.empty?
16
+
17
+ errors = collect_errors(matches, payload)
18
+ return if errors.empty?
19
+
20
+ report(mode, stream, errors)
21
+ end
22
+
23
+ # AsyncAPI 3 treats a channel's messages as alternatives: a payload
24
+ # matching ANY declared message satisfies the channel. We validate
25
+ # against every candidate schema across all matching channels, pass if
26
+ # any validates clean, and otherwise surface the closest match's errors
27
+ # (fewest failures).
28
+ def self.collect_errors(matches, payload)
29
+ results = matches.flat_map do |match|
30
+ match.schema_names.map do |schema_name|
31
+ PayloadValidator.instance.validate(payload, schema_name, match.components)
32
+ end
33
+ end
34
+
35
+ return [] if results.empty?
36
+ return [] if results.any?(&:empty?)
37
+
38
+ results.min_by(&:size)
39
+ end
40
+
41
+ def self.report(mode, stream, errors)
42
+ summary = errors.map { |e| e["error"] }.compact.uniq.join("; ")
43
+ message = "AsyncAPI broadcast validation failed for stream #{stream.inspect}: #{summary}"
44
+
45
+ case mode
46
+ when :warn_only
47
+ logger.warn(message)
48
+ when :enabled
49
+ raise Error, message
50
+ end
51
+ end
52
+
53
+ def self.logger
54
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
55
+ ::Rails.logger
56
+ else
57
+ @fallback_logger ||= Logger.new($stdout)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,129 @@
1
+ require "yaml"
2
+ require "pathname"
3
+
4
+ module AsyncapiCable
5
+ module Runtime
6
+ # Runtime source of truth for broadcast validation: the committed
7
+ # `asyncapi/<schema>.yaml` artifacts, parsed and memoized. This is the
8
+ # read side of the contract; the write side (RSpec specs ->
9
+ # MetadataStore -> AsyncapiWriter) is untouched and remains the
10
+ # authoring path. See docs/exec-plans/asyncapi-cable-runtime-validation.md (D1).
11
+ class ContractRegistry
12
+ Match = Struct.new(:matcher, :schema_names, :components)
13
+
14
+ class << self
15
+ def instance
16
+ @instance ||= new
17
+ end
18
+
19
+ def reset!
20
+ @instance = nil
21
+ end
22
+
23
+ def broadcast_schemas_for(stream) = instance.broadcast_schemas_for(stream)
24
+ end
25
+
26
+ def broadcast_schemas_for(stream)
27
+ channels.select { |match| match.matcher.match?(stream) }
28
+ end
29
+
30
+ def channels
31
+ @channels ||= load_channels
32
+ end
33
+
34
+ private
35
+
36
+ def load_channels
37
+ result = AsyncapiCable.configuration.schemas.keys.flat_map do |schema_name|
38
+ doc = load_document(schema_name)
39
+ doc ? channels_from(doc) : []
40
+ end
41
+ log_load(result.size)
42
+ result
43
+ end
44
+
45
+ def channels_from(doc)
46
+ operations = doc["operations"] || {}
47
+ components = doc["components"] || {}
48
+
49
+ operations.filter_map do |_op_id, op|
50
+ next unless op["action"] == "receive"
51
+
52
+ channel = resolve(doc, op.dig("channel", "$ref"))
53
+ address = channel && channel["address"]
54
+ next unless address
55
+
56
+ schema_names = Array(op["messages"]).filter_map do |msg_ref|
57
+ schema_name_for(doc, msg_ref["$ref"])
58
+ end.uniq
59
+
60
+ Match.new(
61
+ matcher: StreamMatcher.new(address),
62
+ schema_names: schema_names,
63
+ components: components
64
+ )
65
+ end
66
+ end
67
+
68
+ # Walks #/channels/<Ch>/messages/<Key> -> #/components/messages/<M>
69
+ # -> payload.$ref -> #/components/schemas/<Schema>, returning <Schema>.
70
+ def schema_name_for(doc, channel_message_ref)
71
+ channel_message = resolve(doc, channel_message_ref)
72
+ message = channel_message && resolve(doc, channel_message["$ref"])
73
+ payload_ref = message&.dig("payload", "$ref")
74
+ payload_ref&.split("/")&.last
75
+ end
76
+
77
+ def resolve(doc, ref)
78
+ return nil unless ref.is_a?(String) && ref.start_with?("#/")
79
+ doc.dig(*ref.delete_prefix("#/").split("/"))
80
+ end
81
+
82
+ def load_document(schema_name)
83
+ path = schema_path(schema_name)
84
+ return nil unless path.exist?
85
+
86
+ contents = path.read
87
+ return nil if contents.strip.empty?
88
+
89
+ YAML.safe_load(contents)
90
+ rescue => e
91
+ logger.warn("[AsyncapiCable] failed to load contract #{path}: #{e.message}")
92
+ nil
93
+ end
94
+
95
+ def schema_path(schema_name)
96
+ ext = (AsyncapiCable.configuration.schema_output_format.to_sym == :json) ? "json" : "yaml"
97
+ schema_dir.join("#{schema_name}.#{ext}")
98
+ end
99
+
100
+ def schema_dir
101
+ dir = AsyncapiCable.configuration.schema_output_dir.to_s
102
+ pathname = Pathname.new(dir)
103
+ return pathname if pathname.absolute?
104
+
105
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
106
+ ::Rails.root.join(dir)
107
+ else
108
+ pathname
109
+ end
110
+ end
111
+
112
+ def log_load(count)
113
+ if count.zero?
114
+ logger.warn(
115
+ "[AsyncapiCable] contract registry loaded 0 channels; runtime broadcast " \
116
+ "validation is a no-op — check schema_output_dir (#{schema_dir}) and that the " \
117
+ "asyncapi contract files exist"
118
+ )
119
+ else
120
+ logger.info("[AsyncapiCable] contract registry loaded #{count} channel(s)")
121
+ end
122
+ end
123
+
124
+ def logger
125
+ AsyncapiCable::Runtime.logger
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,48 @@
1
+ require "json_schemer"
2
+
3
+ module AsyncapiCable
4
+ module Runtime
5
+ class PayloadValidator
6
+ class << self
7
+ def instance
8
+ @instance ||= new
9
+ end
10
+
11
+ def reset!
12
+ @instance = nil
13
+ end
14
+ end
15
+
16
+ # Validates `payload` against `#/components/schemas/<schema_name>` of
17
+ # the passed-in `components` hash (sourced from the committed YAML, not
18
+ # the live component registry — see exec-plan D4). The JSONSchemer
19
+ # document is memoized per `components` object so repeated broadcasts on
20
+ # the same contract reuse one compiled schema.
21
+ def validate(payload, schema_name, components)
22
+ normalized = stringify_keys(payload)
23
+ ref = "#/components/schemas/#{schema_name}"
24
+ schemer_for(components).ref(ref).validate(normalized).to_a
25
+ end
26
+
27
+ private
28
+
29
+ def schemer_for(components)
30
+ @schemers ||= {}
31
+ @schemers[components.object_id] ||= JSONSchemer.schema(
32
+ {"components" => {"schemas" => components["schemas"] || {}}}
33
+ )
34
+ end
35
+
36
+ def stringify_keys(value)
37
+ case value
38
+ when Hash
39
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys(v) }
40
+ when Array
41
+ value.map { |v| stringify_keys(v) }
42
+ else
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ module AsyncapiCable
2
+ module Runtime
3
+ class StreamMatcher
4
+ def initialize(template)
5
+ @template = template.to_s
6
+ @regex = build_regex(@template)
7
+ end
8
+
9
+ def match?(stream)
10
+ return false if @regex.nil?
11
+ !@regex.match(stream.to_s).nil?
12
+ end
13
+
14
+ private
15
+
16
+ def build_regex(template)
17
+ return nil if template.empty?
18
+
19
+ escaped = Regexp.escape(template).gsub(/\\\{(\w+)\\\}/) { "(?<#{$1}>[^/]+?)" }
20
+ Regexp.new("\\A#{escaped}\\z")
21
+ end
22
+ end
23
+ end
24
+ end