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
|
@@ -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,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,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
|