From 87a19e98c2858b819f161d193fd8190fc3f469c3 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:02:59 +0100 Subject: [PATCH 1/3] fix: resolve external 3.x payload references In 3.x a message payload reference is an AsyncApiMultiFormatSchemaReference. Resolving an external reference only handled JSON and Avro schema references, so an external payload such as `$ref: './payload.json'` was never read and reported "Unable to deserialize reference". The external content is now read as a Multi Format Schema Object, which also accepts a plain Schema Object. Co-Authored-By: Claude Opus 5.5 --- .../AsyncApiJsonDocumentReader.cs | 5 ++ .../Models/AsyncApiReference_Should.cs | 78 ++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs index 9b77719..a544830 100644 --- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs +++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs @@ -410,6 +410,11 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); } + if (reference is AsyncApiMultiFormatSchemaReference) + { + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + } + break; case ReferenceType.Server: diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs index c883748..d430d96 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -29,6 +29,19 @@ public class AsyncApiReference_Should : TestBase } """; + private const string ExternalPayloads = + """ + { + "schemas": { + "plain": { "type": "string" }, + "multiFormat": { + "schemaFormat": "application/vnd.aai.asyncapi+json;version=3.0.0", + "schema": { "type": "integer" } + } + } + } + """; + private static readonly TestCaseData[] ExternalFragmentCases = { new TestCaseData("/components/schemas/plain", SchemaType.String).SetArgDisplayNames("nested path"), @@ -138,6 +151,69 @@ private static AsyncApiDocument ReadWithExternalSchemas(string yaml, out AsyncAp return new AsyncApiStringReader(settings).Read(yaml, out diagnostic); } + [TestCase("payloads.json#/schemas/plain", SchemaType.String)] + [TestCase("payloads.json#/schemas/multiFormat", SchemaType.Integer)] + public void V3_ExternalPayloadReference_ResolvesPayload(string reference, SchemaType expectedType) + { + var yaml = + $""" + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + messages: + message: + payload: + $ref: '{reference}' + """; + + var settings = new AsyncApiReaderSettings + { + ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences, + ExternalReferenceLoader = new MockStringLoader(ExternalPayloads), + }; + + var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + var payload = doc.Channels["workspace"].Messages["message"].Payload; + payload.Should().BeOfType(); + payload.As().UnresolvedReference.Should().BeFalse(); + payload.Schema.As().Type.Should().Be(expectedType); + doc.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().Contain($"$ref: {reference}"); + } + + [Test] + public void V3_ExternalPayloadReference_WithoutFragment_ResolvesPayload() + { + var yaml = + """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + messages: + message: + payload: + $ref: './payload.json' + """; + + var settings = new AsyncApiReaderSettings + { + ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences, + ExternalReferenceLoader = new MockStringLoader("""{ "type": "boolean" }"""), + }; + + var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + doc.Channels["workspace"].Messages["message"].Payload.Schema.As().Type.Should().Be(SchemaType.Boolean); + } + [Test] public void V2_ReferencePointers() { @@ -815,4 +891,4 @@ public Task LoadAsync(Uri baseUri, Uri uri) return Task.FromResult(this.Load(baseUri, uri)); } } -} \ No newline at end of file +} From ed6326724e30477f42c95c2b90785fc9522ee869 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:53:58 +0100 Subject: [PATCH 2/3] fix: read multi-format payload fallbacks in 2.x documents ResolveStreamReference is also the fallback for local references that are not in the workspace, not only for external ones. In a 2.x document every local payload reference is an AsyncApiMultiFormatSchemaReference, so the fallback asked the 2.x reader for an AsyncApiMultiFormatSchema loader it did not have and Read() threw KeyNotFoundException. The 2.x reader now loads a Multi Format Schema by reading the payload as a JSON Schema and wrapping it, since 2.x keeps schemaFormat on the message. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiMessageDeserializer.cs | 8 +++ .../V2/AsyncApiV2VersionService.cs | 1 + .../Models/AsyncApiReference_Should.cs | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs index 618f2f8..1ddb90b 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs @@ -73,6 +73,14 @@ public static IAsyncApiSchema LoadAvroPayload(ParseNode n) return LoadPayload(n, "application/vnd.apache.avro"); } + /// + /// 2.x has no Multi Format Schema Object; the schema format lives on the message, so a payload read on its own is a JSON Schema. + /// + public static AsyncApiMultiFormatSchema LoadMultiFormatSchemaPayload(ParseNode n) + { + return new AsyncApiMultiFormatSchema { Schema = LoadJsonSchemaPayload(n) }; + } + private static IAsyncApiSchema LoadPayload(ParseNode n, string format) { if (n == null) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs index 3fb980b..8f4347f 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs @@ -36,6 +36,7 @@ public AsyncApiV2VersionService(AsyncApiDiagnostic diagnostic) [typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema, [typeof(AsyncApiJsonSchema)] = AsyncApiV2Deserializer.LoadJsonSchemaPayload, [typeof(AsyncApiAvroSchema)] = AsyncApiV2Deserializer.LoadAvroPayload, + [typeof(AsyncApiMultiFormatSchema)] = AsyncApiV2Deserializer.LoadMultiFormatSchemaPayload, [typeof(AsyncApiSecurityScheme)] = AsyncApiV2Deserializer.LoadSecurityScheme, [typeof(AsyncApiServer)] = AsyncApiV2Deserializer.LoadServer, [typeof(AsyncApiServerVariable)] = AsyncApiV2Deserializer.LoadServerVariable, diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs index d430d96..0622f76 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -214,6 +214,55 @@ public void V3_ExternalPayloadReference_WithoutFragment_ResolvesPayload() doc.Channels["workspace"].Messages["message"].Payload.Schema.As().Type.Should().Be(SchemaType.Boolean); } + // A local payload reference that is not a registered component is resolved by falling back to the whole document. + [TestCase("2.6.0")] + [TestCase("3.0.0")] + public void LocalPayloadReference_NotInWorkspace_ResolvesPayload(string version) + { + var yaml = version.StartsWith("2") + ? """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + publish: + message: + payload: + $ref: '#/components/messages/shared/payload' + components: + messages: + shared: + payload: + type: string + """ + : """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + messages: + message: + payload: + $ref: '#/components/messages/shared/payload' + components: + messages: + shared: + payload: + type: string + """; + + var doc = new AsyncApiStringReader().Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + var payload = doc.Channels["workspace"].Messages.Values.First().Payload; + payload.Should().BeOfType(); + payload.Schema.As().Type.Should().Be(SchemaType.String); + } + [Test] public void V2_ReferencePointers() { From 4947bd4e543a7c2efc350d6a8b62828eaf82a5c4 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:24:26 +0100 Subject: [PATCH 3/3] fix: read 2.x Avro payloads in the local reference fallback Adds LocalAvroPayloadReference_NotInWorkspace_ResolvesPayload for 2.6.0 and 3.0.0, as requested in review. The 2.x case failed. For a 2.x document, the whole-document stream that the local fallback reads is the upgraded document serialized as 3.0, so an Avro payload appears there as a Multi Format Schema Object ({ schemaFormat, schema }). The 2.x loader read it as a JSON Schema and reported "schemaFormat/schema is not a valid property". The 2.x reader now uses the 3.x Multi Format Schema loader for that fallback. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiMessageDeserializer.cs | 8 --- .../V2/AsyncApiV2VersionService.cs | 3 +- .../Models/AsyncApiReference_Should.cs | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs index 1ddb90b..618f2f8 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs @@ -73,14 +73,6 @@ public static IAsyncApiSchema LoadAvroPayload(ParseNode n) return LoadPayload(n, "application/vnd.apache.avro"); } - /// - /// 2.x has no Multi Format Schema Object; the schema format lives on the message, so a payload read on its own is a JSON Schema. - /// - public static AsyncApiMultiFormatSchema LoadMultiFormatSchemaPayload(ParseNode n) - { - return new AsyncApiMultiFormatSchema { Schema = LoadJsonSchemaPayload(n) }; - } - private static IAsyncApiSchema LoadPayload(ParseNode n, string format) { if (n == null) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs index 8f4347f..868ace6 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs @@ -36,7 +36,8 @@ public AsyncApiV2VersionService(AsyncApiDiagnostic diagnostic) [typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema, [typeof(AsyncApiJsonSchema)] = AsyncApiV2Deserializer.LoadJsonSchemaPayload, [typeof(AsyncApiAvroSchema)] = AsyncApiV2Deserializer.LoadAvroPayload, - [typeof(AsyncApiMultiFormatSchema)] = AsyncApiV2Deserializer.LoadMultiFormatSchemaPayload, + // 2.x has no Multi Format Schema Object. Only the local reference fallback asks for one, and it reads the document after the upgrade to 3.0. + [typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema, [typeof(AsyncApiSecurityScheme)] = AsyncApiV2Deserializer.LoadSecurityScheme, [typeof(AsyncApiServer)] = AsyncApiV2Deserializer.LoadServer, [typeof(AsyncApiServerVariable)] = AsyncApiV2Deserializer.LoadServerVariable, diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs index 0622f76..f68a4ef 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -263,6 +263,70 @@ public void LocalPayloadReference_NotInWorkspace_ResolvesPayload(string version) payload.Schema.As().Type.Should().Be(SchemaType.String); } + // A local Avro payload reference that is not a registered component is resolved by falling back to the whole document. + [TestCase("2.6.0")] + [TestCase("3.0.0")] + public void LocalAvroPayloadReference_NotInWorkspace_ResolvesPayload(string version) + { + var yaml = version.StartsWith("2") + ? """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + publish: + message: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + payload: + $ref: '#/components/messages/shared/payload' + components: + messages: + shared: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + payload: + type: record + name: User + fields: + - name: name + type: string + """ + : """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + messages: + message: + payload: + $ref: '#/components/messages/shared/payload' + components: + messages: + shared: + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: User + fields: + - name: name + type: string + """; + + var doc = new AsyncApiStringReader().Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + var payload = doc.Channels["workspace"].Messages.Values.First().Payload; + payload.Should().BeOfType(); + payload.SchemaFormat.Should().StartWith("application/vnd.apache.avro"); + var record = payload.Schema.As().As(); + record.Name.Should().Be("User"); + record.Fields.Should().ContainSingle().Which.Name.Should().Be("name"); + } + [Test] public void V2_ReferencePointers() {