From 56aebfb20ba6561848aaa169fa3cef76b8f9bcf7 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:05:44 +0100 Subject: [PATCH 1/3] fix: handle 2.x message traits without headers The 2.x writer threw NullReferenceException for a message trait without headers because it read Headers.SchemaFormat unconditionally. The 2.x reader threw too when schemaFormat appeared without headers, or before them, and a later headers field discarded an earlier schemaFormat. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiMessageTraitDeserializer.cs | 4 +- .../Models/AsyncApiMessageTrait.cs | 6 +-- .../Models/AsyncApiMessage_Should.cs | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageTraitDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageTraitDeserializer.cs index 73e479c..9985071 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageTraitDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageTraitDeserializer.cs @@ -8,9 +8,9 @@ internal static partial class AsyncApiV2Deserializer { private static FixedFieldMap messageTraitFixedFields = new() { - { "headers", (a, n) => { a.Headers = new AsyncApiMultiFormatSchema { Schema = AsyncApiJsonSchemaDeserializer.LoadSchema(n) }; } }, + { "headers", (a, n) => { (a.Headers ??= new AsyncApiMultiFormatSchema()).Schema = AsyncApiJsonSchemaDeserializer.LoadSchema(n); } }, { "correlationId", (a, n) => { a.CorrelationId = LoadCorrelationId(n); } }, - { "schemaFormat", (a, n) => { a.Headers.SchemaFormat = n.GetScalarValue(); } }, + { "schemaFormat", (a, n) => { (a.Headers ??= new AsyncApiMultiFormatSchema()).SchemaFormat = n.GetScalarValue(); } }, { "contentType", (a, n) => { a.ContentType = n.GetScalarValue(); } }, { "name", (a, n) => { a.Name = n.GetScalarValue(); } }, { "title", (a, n) => { a.Title = n.GetScalarValue(); } }, diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiMessageTrait.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiMessageTrait.cs index 82d3867..bf2f991 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiMessageTrait.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiMessageTrait.cs @@ -76,9 +76,9 @@ public virtual void SerializeV2(IAsyncApiWriter writer) writer.WriteStartObject(); //writer.WriteOptionalProperty(AsyncApiConstants.MessageId, this.MessageId); - writer.WriteOptionalObject(AsyncApiConstants.Headers, this.Headers, (w, h) => h.Schema.SerializeV2(w)); + writer.WriteOptionalObject(AsyncApiConstants.Headers, this.Headers?.Schema, (w, h) => h.SerializeV2(w)); writer.WriteOptionalObject(AsyncApiConstants.CorrelationId, this.CorrelationId, (w, c) => c.SerializeV2(w)); - writer.WriteOptionalProperty(AsyncApiConstants.SchemaFormat, this.Headers.SchemaFormat); + writer.WriteOptionalProperty(AsyncApiConstants.SchemaFormat, this.Headers?.SchemaFormat); writer.WriteOptionalProperty(AsyncApiConstants.ContentType, this.ContentType); writer.WriteOptionalProperty(AsyncApiConstants.Name, this.Name); writer.WriteOptionalProperty(AsyncApiConstants.Title, this.Title); @@ -100,7 +100,7 @@ public virtual void SerializeV3(IAsyncApiWriter writer) } writer.WriteStartObject(); - writer.WriteOptionalObject(AsyncApiConstants.Headers, this.Headers, (w, h) => h.SerializeV3(w)); + writer.WriteOptionalObject(AsyncApiConstants.Headers, this.Headers?.Schema == null ? null : this.Headers, (w, h) => h.SerializeV3(w)); writer.WriteOptionalObject(AsyncApiConstants.CorrelationId, this.CorrelationId, (w, c) => c.SerializeV3(w)); writer.WriteOptionalProperty(AsyncApiConstants.ContentType, this.ContentType); writer.WriteOptionalProperty(AsyncApiConstants.Name, this.Name); diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs index e77acf5..5c0e5cf 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs @@ -87,6 +87,52 @@ public void V2_AsyncApiMessage_WithUnsupportedSchemaFormat_DeserializesWithError diagnostic.Errors.First().Message.Should().StartWith("Could not deserialize Payload. Supported formats are"); } + [Test] + public void V2_AsyncApiMessageTrait_WithoutHeaders_Serializes() + { + var expected = + """ + name: TraitName + """; + + var trait = new AsyncApiMessageTrait { Name = "TraitName" }; + + var actual = trait.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V2_AsyncApiMessageTrait_WithSchemaFormatBeforeHeaders_Deserializes() + { + var input = + """ + schemaFormat: application/vnd.aai.asyncapi+json;version=2.6.0 + headers: + type: object + """; + + var trait = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + trait.Headers.SchemaFormat.Should().Be("application/vnd.aai.asyncapi+json;version=2.6.0"); + trait.Headers.Schema.Should().BeOfType(); + } + + [Test] + public void V2_AsyncApiMessageTrait_WithSchemaFormatAndNoHeaders_Deserializes() + { + var input = + """ + schemaFormat: application/vnd.aai.asyncapi+json;version=2.6.0 + """; + + var trait = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + trait.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0).Should().BePlatformAgnosticEquivalentTo(input); + } + [Test] public void V2_AsyncApiMessage_WithNoSchemaFormat_DoesNotSerializeSchemaFormat() { From 4ded72fd6122c085d87aac5fdb92ff247cea1231 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:58:40 +0100 Subject: [PATCH 2/3] test: cover 3.x message trait headers Adds 3.x counterparts to the 2.x message trait tests, so the change to the 3.x writer is shown not to regress: a trait without headers, a trait with headers, and a 2.x trait with schemaFormat but no headers upgraded to 3.x. Co-Authored-By: Claude Opus 5.5 --- .../Models/AsyncApiMessage_Should.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs index 5c0e5cf..a36ea95 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs @@ -133,6 +133,63 @@ public void V2_AsyncApiMessageTrait_WithSchemaFormatAndNoHeaders_Deserializes() trait.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0).Should().BePlatformAgnosticEquivalentTo(input); } + [Test] + public void V3_AsyncApiMessageTrait_WithoutHeaders_Serializes() + { + var expected = + """ + name: TraitName + """; + + var trait = new AsyncApiMessageTrait { Name = "TraitName" }; + + var actual = trait.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V3_AsyncApiMessageTrait_WithHeaders_Reserializes() + { + var input = + """ + headers: + type: object + name: TraitName + """; + + // The 3.x reader fills in the default schema format, so the headers come back in the explicit multi-format form. + var expected = + """ + headers: + schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0 + schema: + type: object + name: TraitName + """; + + var trait = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + trait.Headers.Schema.Should().BeOfType(); + trait.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V2_AsyncApiMessageTrait_WithSchemaFormatAndNoHeaders_UpgradesToV3WithoutHeaders() + { + var input = + """ + schemaFormat: application/vnd.aai.asyncapi+json;version=2.6.0 + name: TraitName + """; + + var trait = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + trait.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().BePlatformAgnosticEquivalentTo("name: TraitName"); + } + [Test] public void V2_AsyncApiMessage_WithNoSchemaFormat_DoesNotSerializeSchemaFormat() { From 4f3a7d7202dd6542844b96584af4900d20502549 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:39:06 +0100 Subject: [PATCH 3/3] test: note that 3.x headers are always written as multi-format by design Co-Authored-By: Claude Opus 5.5 --- test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs index a36ea95..39d38c7 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiMessage_Should.cs @@ -158,7 +158,7 @@ public void V3_AsyncApiMessageTrait_WithHeaders_Reserializes() name: TraitName """; - // The 3.x reader fills in the default schema format, so the headers come back in the explicit multi-format form. + // By design, 3.x schemas are always read as multi-format schemas with the default schema format filled in, so the headers come back in the explicit multi-format form. var expected = """ headers: