From cdfc29dbf592ee5912d58f82abbe70fbdfd6f956 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/4] fix: write 2.x channel servers as server names AsyncAPI 2.x lists a channel's servers by name, but the 2.x writer emitted the reference fragment, so "#/servers/production" was written as "/servers/production". This also broke a 2.x read/write round trip. Co-Authored-By: Claude Opus 5.5 --- .../Models/AsyncApiChannel.cs | 17 ++++++- .../Models/AsyncApiChannel_Should.cs | 47 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs index 87b0a25..a0d2b9f 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs @@ -84,7 +84,7 @@ public virtual void SerializeV2(IAsyncApiWriter writer) writer.WriteOptionalProperty(AsyncApiConstants.Description, this.Description); // servers - writer.WriteOptionalCollection(AsyncApiConstants.Servers, this.Servers.Select(s => s.Reference.FragmentId).ToList(), (w, s) => w.WriteValue(s)); + writer.WriteOptionalCollection(AsyncApiConstants.Servers, this.Servers.Select(GetV2ServerName).ToList(), (w, s) => w.WriteValue(s)); var operations = writer.Workspace.RootDocument?.Operations.Values.Where(operation => CheckOperationChannel(operation, writer)).ToList(); @@ -166,5 +166,20 @@ private bool CheckOperationChannel(AsyncApiOperation operation, IAsyncApiWriter operation.Channel.Reference.Workspace = writer.Workspace; return operation.Channel.Equals(this); } + + /// + /// AsyncAPI 2.x lists channel servers by name, so a "#/servers/{name}" pointer is written as "{name}". + /// + private static string GetV2ServerName(AsyncApiServerReference server) + { + var fragmentId = server.Reference.FragmentId; + if (fragmentId == null) + { + return server.Reference.Reference; + } + + var tokens = new JsonPointer(fragmentId).Tokens; + return tokens.Length == 2 && tokens[0] == AsyncApiConstants.Servers ? tokens[1] : fragmentId; + } } } \ No newline at end of file diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs index 24314e8..86c8194 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs @@ -156,6 +156,53 @@ public void V2_AsyncApiChannel_UpgradesAndNormalizesKey() reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); } + [Test] + public void V2_AsyncApiChannel_WithServers_SerializesServerNames() + { + var yaml = + """ + asyncapi: 2.6.0 + info: + title: test spec + version: 1.0.0 + servers: + production: + url: example.com + protocol: kafka + channels: + mychannel: + servers: + - production + """; + + var document = new AsyncApiStringReader().Read(yaml, out var diag); + + document.Channels.First().Value.Servers.Single().Reference.Reference.Should().Be("#/servers/production"); + + var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); + } + + [Test] + public void V2_AsyncApiChannel_WithServerReference_SerializesServerName() + { + var expected = + """ + servers: + - production + """; + + var channel = new AsyncApiChannel + { + Servers = new List { new AsyncApiServerReference("#/servers/production") }, + }; + + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + } + [Test] public void V2_AsyncApiChannel_reserializes() { From c7e570e0861ebe65826b3240ded0758bdc558f3c Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:55:27 +0100 Subject: [PATCH 2/4] fix: keep non-local 2.x channel server references intact Only a local "#/servers/{name}" reference is reduced to a server name. An external reference, or any other pointer, is written as is, since reducing it to its last token would silently point the channel at a local server of the same name. The name is decoded as one token, so both the 2.x reader's unescaped form and an escaped 3.x pointer give the same name. Also adds 3.x channel server tests, so the change is shown not to regress 3.x serialization or the 2.x to 3.x upgrade. Co-Authored-By: Claude Opus 5.5 --- .../Models/AsyncApiChannel.cs | 14 +-- .../Models/AsyncApiChannel_Should.cs | 93 +++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs index a0d2b9f..cc9c687 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs @@ -168,18 +168,20 @@ private bool CheckOperationChannel(AsyncApiOperation operation, IAsyncApiWriter } /// - /// AsyncAPI 2.x lists channel servers by name, so a "#/servers/{name}" pointer is written as "{name}". + /// AsyncAPI 2.x lists channel servers by name, so a local "#/servers/{name}" pointer is written as "{name}". + /// Any other reference is written as is, since reducing it to a name would point it at a local server instead. /// private static string GetV2ServerName(AsyncApiServerReference server) { - var fragmentId = server.Reference.FragmentId; - if (fragmentId == null) + const string serversPrefix = "/" + AsyncApiConstants.Servers + "/"; + var reference = server.Reference; + if (reference.IsExternal || reference.FragmentId?.StartsWith(serversPrefix) != true) { - return server.Reference.Reference; + return reference.Reference; } - var tokens = new JsonPointer(fragmentId).Tokens; - return tokens.Length == 2 && tokens[0] == AsyncApiConstants.Servers ? tokens[1] : fragmentId; + // The 2.x reader keeps the name unescaped, while a 3.x pointer escapes it, so decode the whole remainder as one token. + return reference.FragmentId.Substring(serversPrefix.Length).Replace("~1", "/").Replace("~0", "~"); } } } \ No newline at end of file diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs index 86c8194..bbebae1 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs @@ -203,6 +203,99 @@ public void V2_AsyncApiChannel_WithServerReference_SerializesServerName() actual.Should().BePlatformAgnosticEquivalentTo(expected); } + [TestCase("#/servers/east~1west", "east/west")] + [TestCase("#/servers/a~0b", "a~b")] + [TestCase("other.yaml#/servers/production", "other.yaml#/servers/production")] + [TestCase("#/components/servers/production", "'#/components/servers/production'")] + public void V2_AsyncApiChannel_WithServerReference_SerializesServerNameOnlyForLocalServer(string reference, string expectedServer) + { + var channel = new AsyncApiChannel + { + Servers = new List { new AsyncApiServerReference(reference) }, + }; + + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo($"servers:\n - {expectedServer}"); + } + + [Test] + public void V3_AsyncApiChannel_WithServers_Reserializes() + { + var yaml = + """ + asyncapi: 3.1.0 + info: + title: test spec + version: 1.0.0 + servers: + production: + host: example.com + protocol: kafka + channels: + mychannel: + servers: + - $ref: '#/servers/production' + components: { } + """; + + var document = new AsyncApiStringReader().Read(yaml, out var diag); + + diag.Errors.Should().BeEmpty(); + var server = document.Channels.First().Value.Servers.Single(); + server.Reference.Reference.Should().Be("#/servers/production"); + server.Host.Should().Be("example.com"); + + var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); + } + + [Test] + public void V3_AsyncApiChannel_WithServerReference_SerializesReference() + { + var expected = + """ + servers: + - $ref: '#/servers/production' + """; + + var channel = new AsyncApiChannel + { + Servers = new List { new AsyncApiServerReference("#/servers/production") }, + }; + + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V2_AsyncApiChannel_WithServers_UpgradesToV3References() + { + var yaml = + """ + asyncapi: 2.6.0 + info: + title: test spec + version: 1.0.0 + servers: + production: + url: example.com + protocol: kafka + channels: + mychannel: + servers: + - production + """; + + var document = new AsyncApiStringReader().Read(yaml, out var diag); + + var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + reserialized.Should().Contain("- $ref: '#/servers/production'"); + } + [Test] public void V2_AsyncApiChannel_reserializes() { From 95326dd64fef3bc0f23aae7958e98996f181c661 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:01:55 +0100 Subject: [PATCH 3/4] fix: escape 2.x channel server names in server references The 2.x reader built "#/servers/{name}" without escaping the name, while the writer decodes the pointer token. A literal name such as "a~1b" therefore came back as "a/b" after a read/write round trip. The reader now escapes the name, and the workspace registers document servers under the escaped pointer so the reference still resolves. Server names this needs are outside the spec's key pattern, so this only matters when validation is off. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiChannelDeserializer.cs | 2 +- src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs | 2 +- src/ByteBard.AsyncAPI/JsonPointer.cs | 21 ++++++- .../Models/AsyncApiChannel.cs | 2 +- .../Models/AsyncApiChannel_Should.cs | 63 +++++++++++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs index 424da0a..5a1cf3e 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs @@ -21,7 +21,7 @@ internal static partial class AsyncApiV2Deserializer private static string GetServerReferenceKey(ValueNode valueNode) { var stringValue = valueNode.GetScalarValue(); - return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + stringValue; + return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + JsonPointer.EscapeToken(stringValue); } private static readonly PatternFieldMap ChannelPatternFields = diff --git a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs index 3f48893..43a6f1e 100644 --- a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs +++ b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs @@ -219,7 +219,7 @@ public void RegisterComponents(AsyncApiDocument document) registerableServerValue = this.ResolveReference(serverReference.Reference); } - location = serverBaseUri + server.Key; + location = serverBaseUri + JsonPointer.EscapeToken(server.Key); this.RegisterComponent(location, registerableServerValue); } } diff --git a/src/ByteBard.AsyncAPI/JsonPointer.cs b/src/ByteBard.AsyncAPI/JsonPointer.cs index 28c8b23..59a664c 100644 --- a/src/ByteBard.AsyncAPI/JsonPointer.cs +++ b/src/ByteBard.AsyncAPI/JsonPointer.cs @@ -83,12 +83,31 @@ public static JsonPointer FromUriFragment(string fragment) return new JsonPointer(Uri.UnescapeDataString(fragment)); } + /// + /// Escapes a single reference token, so "~" becomes "~0" and "/" becomes "~1". + /// + /// The unescaped token. + public static string EscapeToken(string token) + { + return token.Replace("~", "~0").Replace("/", "~1"); + } + + /// + /// Unescapes a single reference token, so "~1" becomes "/" and "~0" becomes "~". + /// Unlike parsing a pointer, this does not reject a "~" that is not part of an escape sequence. + /// + /// The escaped token. + public static string UnescapeToken(string token) + { + return token.Replace("~1", "/").Replace("~0", "~"); + } + /// /// Gets the JSON string representation of this JSON pointer. /// public override string ToString() { - return string.Concat(this.Tokens.Select(token => "/" + token.Replace("~", "~0").Replace("/", "~1"))); + return string.Concat(this.Tokens.Select(token => "/" + EscapeToken(token))); } private static string Unescape(string token, string pointer) diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs index cc9c687..801cc81 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs @@ -181,7 +181,7 @@ private static string GetV2ServerName(AsyncApiServerReference server) } // The 2.x reader keeps the name unescaped, while a 3.x pointer escapes it, so decode the whole remainder as one token. - return reference.FragmentId.Substring(serversPrefix.Length).Replace("~1", "/").Replace("~0", "~"); + return JsonPointer.UnescapeToken(reference.FragmentId.Substring(serversPrefix.Length)); } } } \ No newline at end of file diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs index bbebae1..4dea6fa 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs @@ -8,6 +8,7 @@ using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Readers; + using ByteBard.AsyncAPI.Validations; using NUnit.Framework; public class AsyncApiChannel_Should : TestBase @@ -251,6 +252,68 @@ public void V3_AsyncApiChannel_WithServers_Reserializes() reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); } + [TestCase("a~1b", "#/servers/a~01b")] + [TestCase("a/b", "#/servers/a~1b")] + public void V2_AsyncApiChannel_WithServerNameNeedingEscape_Reserializes(string serverName, string expectedReference) + { + var yaml = + $""" + asyncapi: 2.6.0 + info: + title: test spec + version: 1.0.0 + servers: + {serverName}: + url: example.com + protocol: kafka + channels: + mychannel: + servers: + - {serverName} + """; + + var document = new AsyncApiStringReader(new AsyncApiReaderSettings { RuleSet = ValidationRuleSet.GetEmptyRuleSet() }).Read(yaml, out var diag); + + diag.Errors.Should().BeEmpty(); + var server = document.Channels.First().Value.Servers.Single(); + server.Reference.Reference.Should().Be(expectedReference); + server.Host.Should().Be("example.com"); + + var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); + } + + [Test] + public void V3_AsyncApiChannel_WithEscapedServerReference_ResolvesServer() + { + var yaml = + """ + asyncapi: 3.1.0 + info: + title: test spec + version: 1.0.0 + servers: + a/b: + host: example.com + protocol: kafka + channels: + mychannel: + servers: + - $ref: '#/servers/a~1b' + components: { } + """; + + var document = new AsyncApiStringReader(new AsyncApiReaderSettings { RuleSet = ValidationRuleSet.GetEmptyRuleSet() }).Read(yaml, out var diag); + + diag.Errors.Should().BeEmpty(); + document.Channels.First().Value.Servers.Single().Host.Should().Be("example.com"); + + var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + reserialized.Should().BePlatformAgnosticEquivalentTo(yaml); + } + [Test] public void V3_AsyncApiChannel_WithServerReference_SerializesReference() { From b61e78fd4b08f0ca13fdeec3a6f8952aed15e776 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:43:58 +0100 Subject: [PATCH 4/4] fix: parse 2.x channel server references with the strict JsonPointer Removes the lenient JsonPointer.UnescapeToken. The 2.x writer now parses the reference fragment with JsonPointer, so a name is only written for an exact /servers/{name} pointer. Nested or malformed pointers (for example a '~' not followed by '0' or '1') are written unchanged, like other non-server references. Co-Authored-By: Claude Opus 5.5 --- src/ByteBard.AsyncAPI/JsonPointer.cs | 10 ---------- .../Models/AsyncApiChannel.cs | 18 ++++++++++++++---- .../Models/AsyncApiChannel_Should.cs | 3 +++ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/ByteBard.AsyncAPI/JsonPointer.cs b/src/ByteBard.AsyncAPI/JsonPointer.cs index 59a664c..8bd1951 100644 --- a/src/ByteBard.AsyncAPI/JsonPointer.cs +++ b/src/ByteBard.AsyncAPI/JsonPointer.cs @@ -92,16 +92,6 @@ public static string EscapeToken(string token) return token.Replace("~", "~0").Replace("/", "~1"); } - /// - /// Unescapes a single reference token, so "~1" becomes "/" and "~0" becomes "~". - /// Unlike parsing a pointer, this does not reject a "~" that is not part of an escape sequence. - /// - /// The escaped token. - public static string UnescapeToken(string token) - { - return token.Replace("~1", "/").Replace("~0", "~"); - } - /// /// Gets the JSON string representation of this JSON pointer. /// diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs index 801cc81..9124b85 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; + using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Extensions; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Writers; @@ -173,15 +174,24 @@ private bool CheckOperationChannel(AsyncApiOperation operation, IAsyncApiWriter /// private static string GetV2ServerName(AsyncApiServerReference server) { - const string serversPrefix = "/" + AsyncApiConstants.Servers + "/"; var reference = server.Reference; - if (reference.IsExternal || reference.FragmentId?.StartsWith(serversPrefix) != true) + if (reference.IsExternal || reference.FragmentId == null) { return reference.Reference; } - // The 2.x reader keeps the name unescaped, while a 3.x pointer escapes it, so decode the whole remainder as one token. - return JsonPointer.UnescapeToken(reference.FragmentId.Substring(serversPrefix.Length)); + string[] tokens; + try + { + tokens = new JsonPointer(reference.FragmentId).Tokens; + } + catch (AsyncApiException) + { + // A malformed pointer names no server, so it is kept as is like any other non-server reference. + return reference.Reference; + } + + return tokens.Length == 2 && tokens[0] == AsyncApiConstants.Servers ? tokens[1] : reference.Reference; } } } \ No newline at end of file diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs index 4dea6fa..6749e5b 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs @@ -208,6 +208,9 @@ public void V2_AsyncApiChannel_WithServerReference_SerializesServerName() [TestCase("#/servers/a~0b", "a~b")] [TestCase("other.yaml#/servers/production", "other.yaml#/servers/production")] [TestCase("#/components/servers/production", "'#/components/servers/production'")] + [TestCase("#/servers/production/url", "'#/servers/production/url'")] + [TestCase("#/servers/a~2b", "'#/servers/a~2b'")] + [TestCase("#/servers/a~", "'#/servers/a~'")] public void V2_AsyncApiChannel_WithServerReference_SerializesServerNameOnlyForLocalServer(string reference, string expectedServer) { var channel = new AsyncApiChannel