Skip to content

Commit e816843

Browse files
bieluclaudeVisualBean
authored
fix: resolve external 3.x payload references (#59)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Co-authored-by: Alex Wichmann <VisualBean@users.noreply.github.com>
1 parent ff96a92 commit e816843

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

‎src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,11 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
410410
result = this.ReadFragment<AsyncApiAvroSchema>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
411411
}
412412

413+
if (reference is AsyncApiMultiFormatSchemaReference)
414+
{
415+
result = this.ReadFragment<AsyncApiMultiFormatSchema>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
416+
}
417+
413418
break;
414419

415420
case ReferenceType.Server:

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public AsyncApiV2VersionService(AsyncApiDiagnostic diagnostic)
3636
[typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema,
3737
[typeof(AsyncApiJsonSchema)] = AsyncApiV2Deserializer.LoadJsonSchemaPayload,
3838
[typeof(AsyncApiAvroSchema)] = AsyncApiV2Deserializer.LoadAvroPayload,
39+
// 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.
40+
[typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema,
3941
[typeof(AsyncApiSecurityScheme)] = AsyncApiV2Deserializer.LoadSecurityScheme,
4042
[typeof(AsyncApiServer)] = AsyncApiV2Deserializer.LoadServer,
4143
[typeof(AsyncApiServerVariable)] = AsyncApiV2Deserializer.LoadServerVariable,

‎test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs‎

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ public class AsyncApiReference_Should : TestBase
2929
}
3030
""";
3131

32+
private const string ExternalPayloads =
33+
"""
34+
{
35+
"schemas": {
36+
"plain": { "type": "string" },
37+
"multiFormat": {
38+
"schemaFormat": "application/vnd.aai.asyncapi+json;version=3.0.0",
39+
"schema": { "type": "integer" }
40+
}
41+
}
42+
}
43+
""";
44+
3245
private static readonly TestCaseData[] ExternalFragmentCases =
3346
{
3447
new TestCaseData("/components/schemas/plain", SchemaType.String).SetArgDisplayNames("nested path"),
@@ -138,6 +151,182 @@ private static AsyncApiDocument ReadWithExternalSchemas(string yaml, out AsyncAp
138151
return new AsyncApiStringReader(settings).Read(yaml, out diagnostic);
139152
}
140153

154+
[TestCase("payloads.json#/schemas/plain", SchemaType.String)]
155+
[TestCase("payloads.json#/schemas/multiFormat", SchemaType.Integer)]
156+
public void V3_ExternalPayloadReference_ResolvesPayload(string reference, SchemaType expectedType)
157+
{
158+
var yaml =
159+
$"""
160+
asyncapi: 3.0.0
161+
info:
162+
title: test
163+
version: 1.0.0
164+
channels:
165+
workspace:
166+
messages:
167+
message:
168+
payload:
169+
$ref: '{reference}'
170+
""";
171+
172+
var settings = new AsyncApiReaderSettings
173+
{
174+
ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences,
175+
ExternalReferenceLoader = new MockStringLoader(ExternalPayloads),
176+
};
177+
178+
var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic);
179+
180+
diagnostic.Errors.Should().BeEmpty();
181+
var payload = doc.Channels["workspace"].Messages["message"].Payload;
182+
payload.Should().BeOfType<AsyncApiMultiFormatSchemaReference>();
183+
payload.As<AsyncApiMultiFormatSchemaReference>().UnresolvedReference.Should().BeFalse();
184+
payload.Schema.As<AsyncApiJsonSchema>().Type.Should().Be(expectedType);
185+
doc.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().Contain($"$ref: {reference}");
186+
}
187+
188+
[Test]
189+
public void V3_ExternalPayloadReference_WithoutFragment_ResolvesPayload()
190+
{
191+
var yaml =
192+
"""
193+
asyncapi: 3.0.0
194+
info:
195+
title: test
196+
version: 1.0.0
197+
channels:
198+
workspace:
199+
messages:
200+
message:
201+
payload:
202+
$ref: './payload.json'
203+
""";
204+
205+
var settings = new AsyncApiReaderSettings
206+
{
207+
ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences,
208+
ExternalReferenceLoader = new MockStringLoader("""{ "type": "boolean" }"""),
209+
};
210+
211+
var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic);
212+
213+
diagnostic.Errors.Should().BeEmpty();
214+
doc.Channels["workspace"].Messages["message"].Payload.Schema.As<AsyncApiJsonSchema>().Type.Should().Be(SchemaType.Boolean);
215+
}
216+
217+
// A local payload reference that is not a registered component is resolved by falling back to the whole document.
218+
[TestCase("2.6.0")]
219+
[TestCase("3.0.0")]
220+
public void LocalPayloadReference_NotInWorkspace_ResolvesPayload(string version)
221+
{
222+
var yaml = version.StartsWith("2")
223+
? """
224+
asyncapi: 2.6.0
225+
info:
226+
title: test
227+
version: 1.0.0
228+
channels:
229+
workspace:
230+
publish:
231+
message:
232+
payload:
233+
$ref: '#/components/messages/shared/payload'
234+
components:
235+
messages:
236+
shared:
237+
payload:
238+
type: string
239+
"""
240+
: """
241+
asyncapi: 3.0.0
242+
info:
243+
title: test
244+
version: 1.0.0
245+
channels:
246+
workspace:
247+
messages:
248+
message:
249+
payload:
250+
$ref: '#/components/messages/shared/payload'
251+
components:
252+
messages:
253+
shared:
254+
payload:
255+
type: string
256+
""";
257+
258+
var doc = new AsyncApiStringReader().Read(yaml, out var diagnostic);
259+
260+
diagnostic.Errors.Should().BeEmpty();
261+
var payload = doc.Channels["workspace"].Messages.Values.First().Payload;
262+
payload.Should().BeOfType<AsyncApiMultiFormatSchemaReference>();
263+
payload.Schema.As<AsyncApiJsonSchema>().Type.Should().Be(SchemaType.String);
264+
}
265+
266+
// A local Avro payload reference that is not a registered component is resolved by falling back to the whole document.
267+
[TestCase("2.6.0")]
268+
[TestCase("3.0.0")]
269+
public void LocalAvroPayloadReference_NotInWorkspace_ResolvesPayload(string version)
270+
{
271+
var yaml = version.StartsWith("2")
272+
? """
273+
asyncapi: 2.6.0
274+
info:
275+
title: test
276+
version: 1.0.0
277+
channels:
278+
workspace:
279+
publish:
280+
message:
281+
schemaFormat: application/vnd.apache.avro;version=1.9.0
282+
payload:
283+
$ref: '#/components/messages/shared/payload'
284+
components:
285+
messages:
286+
shared:
287+
schemaFormat: application/vnd.apache.avro;version=1.9.0
288+
payload:
289+
type: record
290+
name: User
291+
fields:
292+
- name: name
293+
type: string
294+
"""
295+
: """
296+
asyncapi: 3.0.0
297+
info:
298+
title: test
299+
version: 1.0.0
300+
channels:
301+
workspace:
302+
messages:
303+
message:
304+
payload:
305+
$ref: '#/components/messages/shared/payload'
306+
components:
307+
messages:
308+
shared:
309+
payload:
310+
schemaFormat: application/vnd.apache.avro;version=1.9.0
311+
schema:
312+
type: record
313+
name: User
314+
fields:
315+
- name: name
316+
type: string
317+
""";
318+
319+
var doc = new AsyncApiStringReader().Read(yaml, out var diagnostic);
320+
321+
diagnostic.Errors.Should().BeEmpty();
322+
var payload = doc.Channels["workspace"].Messages.Values.First().Payload;
323+
payload.Should().BeOfType<AsyncApiMultiFormatSchemaReference>();
324+
payload.SchemaFormat.Should().StartWith("application/vnd.apache.avro");
325+
var record = payload.Schema.As<AsyncApiAvroSchema>().As<AvroRecord>();
326+
record.Name.Should().Be("User");
327+
record.Fields.Should().ContainSingle().Which.Name.Should().Be("name");
328+
}
329+
141330
[Test]
142331
public void V2_ReferencePointers()
143332
{
@@ -815,4 +1004,4 @@ public Task<Stream> LoadAsync(Uri baseUri, Uri uri)
8151004
return Task.FromResult(this.Load(baseUri, uri));
8161005
}
8171006
}
818-
}
1007+
}

0 commit comments

Comments
 (0)