From 74c415e5ca0c3db5b035904d87c914f621c06568 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Sat, 24 Oct 2026 23:01:06 +0200 Subject: [PATCH] fix!: escape keys in local reference pointers Keys containing "/" or "~" were registered and referenced unescaped, so a spec-correct pointer such as "#/channels/user~1signedup" missed the workspace and was resolved from the raw document into a separate copy of the channel. Unescaped pointers only resolved because both sides skipped escaping. The workspace now registers every component, channel, channel message and server under its escaped pointer, and the 2.x reader escapes the keys of every reference it builds. Names recovered from references (2.x message keys, 2.x channel servers and security requirement names) are taken from the parsed pointer, so they come back unescaped. This keeps 2.x and 3.x reading, writing, upgrading and downgrading intact for such keys. BREAKING CHANGE: a local reference to a key containing "/" or "~" must escape it as "~1" or "~0" (RFC 6901). The unescaped form no longer resolves. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiChannelDeserializer.cs | 2 +- .../V2/AsyncApiDocumentDeserializer.cs | 4 +- .../V2/AsyncApiOperationDeserializer.cs | 7 +- ...AsyncApiSecurityRequirementDeserializer.cs | 2 +- src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs | 46 +-- src/ByteBard.AsyncAPI/JsonPointer.cs | 36 ++- .../Models/AsyncApiChannel.cs | 20 +- .../AsyncApiSecuritySchemeReference.cs | 4 +- .../JsonPointer_Should.cs | 10 + .../Models/EscapedReferenceKeys_Should.cs | 302 ++++++++++++++++++ 10 files changed, 401 insertions(+), 32 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/Models/EscapedReferenceKeys_Should.cs 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.Readers/V2/AsyncApiDocumentDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs index b938918..0ebe468 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs @@ -136,7 +136,7 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && reference.Reference.Reference.EndsWith(operationChannelFragmentKey)); if (correctChannelReference.Key != null) { - operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + correctChannelReference.Key); + operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeToken(correctChannelReference.Key)); channel = correctChannelReference; } else @@ -161,7 +161,7 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum foreach (var message in messages) { channel.Value.Messages.TryAdd(message.Key, message.Value); - operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{channel.Key}/messages/{message.Key}")); + operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{JsonPointer.EscapeToken(channel.Key)}/messages/{JsonPointer.EscapeToken(message.Key)}")); } } } diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs index 8c79bd1..05c8734 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs @@ -48,7 +48,10 @@ private static KeyValuePair GetMessage(ParseNode node) var message = LoadMessage(node); if (message is AsyncApiMessageReference reference) { - key = reference.Reference.Reference.Split('/').Last(); + // Use the unescaped last pointer token, so the key matches the referenced component's key. + key = reference.Reference.IsFragment && JsonPointer.TryFromUriFragment(reference.Reference.FragmentId, out var pointer) && pointer.Tokens.Length > 0 + ? pointer.Tokens.Last() + : reference.Reference.Reference.Split('/').Last(); } else if (messageNode["messageId"] != null) { @@ -87,7 +90,7 @@ private static void LoadMessages(ParseNode n, AsyncApiOperation instance) { if (message.Value is not AsyncApiMessageReference messageReference) { - var componentReference = "#/components/messages/" + message.Key; + var componentReference = "#/components/messages/" + JsonPointer.EscapeToken(message.Key); componentMessages.Add(message.Key, message.Value); componentMessageReferences.Add(message.Key, new AsyncApiMessageReference(componentReference)); continue; diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiSecurityRequirementDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiSecurityRequirementDeserializer.cs index f9aa8fe..2efa3e1 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiSecurityRequirementDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiSecurityRequirementDeserializer.cs @@ -38,7 +38,7 @@ private static AsyncApiSecuritySchemeReference LoadSecuritySchemeByReference( ParsingContext context, string schemeName) { - var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + schemeName); + var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + JsonPointer.EscapeToken(schemeName)); return securitySchemeObject; } diff --git a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs index 3f48893..62d723e 100644 --- a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs +++ b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs @@ -44,56 +44,56 @@ public void RegisterComponents(AsyncApiDocument document) // Register Schema foreach (var item in document.Components.Schemas) { - location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Parameters foreach (var item in document.Components.Parameters) { - location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Channels foreach (var item in document.Components.Channels) { - location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Operations foreach (var item in document.Components.Operations) { - location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Servers foreach (var item in document.Components.Servers) { - location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ServerVariables foreach (var item in document.Components.ServerVariables) { - location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Messages foreach (var item in document.Components.Messages) { - location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register SecuritySchemes foreach (var item in document.Components.SecuritySchemes) { - location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); this.RegisterComponent(item.Key, item.Value); } @@ -101,84 +101,84 @@ public void RegisterComponents(AsyncApiDocument document) // Register Server Variables foreach (var item in document.Components.ServerVariables) { - location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register CorrelationIds foreach (var item in document.Components.CorrelationIds) { - location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Replies foreach (var item in document.Components.Replies) { - location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ReplyAddresses foreach (var item in document.Components.ReplyAddresses) { - location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ExternalDocs foreach (var item in document.Components.ExternalDocs) { - location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Tags foreach (var item in document.Components.Tags) { - location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register OperationTraits foreach (var item in document.Components.OperationTraits) { - location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register MessageTraits foreach (var item in document.Components.MessageTraits) { - location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ServerBindings foreach (var item in document.Components.ServerBindings) { - location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ChannelBindings foreach (var item in document.Components.ChannelBindings) { - location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register OperationBindings foreach (var item in document.Components.OperationBindings) { - location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } // Register MessageBindings foreach (var item in document.Components.MessageBindings) { - location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + item.Key; + location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); this.RegisterComponent(location, item.Value); } @@ -196,12 +196,12 @@ public void RegisterComponents(AsyncApiDocument document) registerableChannelValue = this.ResolveReference(reference.Reference); } - location = channelBaseUri + channel.Key; + location = channelBaseUri + JsonPointer.EscapeToken(channel.Key); this.RegisterComponent(location, registerableChannelValue); foreach (var message in registerableChannelValue.Messages) { - this.RegisterComponent(location + "/messages/" + message.Key, message.Value); + this.RegisterComponent(location + "/messages/" + JsonPointer.EscapeToken(message.Key), message.Value); } } @@ -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..597d0d5 100644 --- a/src/ByteBard.AsyncAPI/JsonPointer.cs +++ b/src/ByteBard.AsyncAPI/JsonPointer.cs @@ -83,12 +83,46 @@ public static JsonPointer FromUriFragment(string fragment) return new JsonPointer(Uri.UnescapeDataString(fragment)); } + /// + /// Tries to create a pointer from its URI fragment identifier representation (RFC 6901 section 6). + /// + /// The fragment, without the leading #. + /// The pointer, or null when the fragment is not a valid JSON pointer. + /// true when the fragment is a valid JSON pointer. + public static bool TryFromUriFragment(string fragment, out JsonPointer pointer) + { + pointer = null; + if (fragment == null) + { + return false; + } + + try + { + pointer = FromUriFragment(fragment); + return true; + } + catch (AsyncApiException) + { + return false; + } + } + + /// + /// 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"); + } + /// /// 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 87b0a25..2864050 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,23 @@ 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 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 reference = server.Reference; + if (!reference.IsExternal + && JsonPointer.TryFromUriFragment(reference.FragmentId, out var pointer) + && pointer.Tokens.Length == 2 + && pointer.Tokens[0] == AsyncApiConstants.Servers) + { + return pointer.Tokens[1]; + } + + return reference.Reference; + } } } \ No newline at end of file diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiSecuritySchemeReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiSecuritySchemeReference.cs index 55d8fd8..a4f8d10 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiSecuritySchemeReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiSecuritySchemeReference.cs @@ -88,7 +88,9 @@ public void SerializeAsSecurityRequirement(IAsyncApiWriter writer) this.Reference.Workspace = writer.Workspace; writer.WriteStartObject(); - writer.WritePropertyName(this.Reference.FragmentId.Split('/').Last()); + // 2.x security requirements list schemes by name: the unescaped last pointer token, or the bare name. + var fragmentId = this.Reference.FragmentId; + writer.WritePropertyName(JsonPointer.TryFromUriFragment(fragmentId, out var pointer) && pointer.Tokens.Length > 0 ? pointer.Tokens.Last() : fragmentId); writer.WriteStartArray(); if (this.Scopes.Any()) diff --git a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs index 520dd24..85b4398 100644 --- a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs @@ -77,6 +77,16 @@ public void StringRepresentation_IsNotPercentDecoded() new JsonPointer("/a%20b").Find(json).Should().BeNull(); } + [TestCase("plain", "plain")] + [TestCase("a/b", "a~1b")] + [TestCase("a~b", "a~0b")] + [TestCase("~1", "~01")] + public void EscapeToken_EscapesTildeBeforeSlash(string token, string expected) + { + JsonPointer.EscapeToken(token).Should().Be(expected); + new JsonPointer("/" + expected).Tokens.Should().Equal(token); + } + [Test] public void Unescape_DecodesTilde1BeforeTilde0() { diff --git a/test/ByteBard.AsyncAPI.Tests/Models/EscapedReferenceKeys_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/EscapedReferenceKeys_Should.cs new file mode 100644 index 0000000..c8cf023 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/EscapedReferenceKeys_Should.cs @@ -0,0 +1,302 @@ +namespace ByteBard.AsyncAPI.Tests +{ + using System.Linq; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using FluentAssertions; + using NUnit.Framework; + + /// + /// Keys containing "/" or "~" must be escaped as "~1" and "~0" in reference pointers (RFC 6901). + /// Such keys fail the spec's key patterns, but the documents still read, write, upgrade and downgrade. + /// + public class EscapedReferenceKeys_Should + { + private const string V3Document = + """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + servers: + prod~eu: + host: example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/basic~1auth' + channels: + user/signedup: + address: user/signedup + servers: + - $ref: '#/servers/prod~0eu' + messages: + signed/up: + $ref: '#/components/messages/signed~1up' + operations: + op: + action: send + channel: + $ref: '#/channels/user~1signedup' + messages: + - $ref: '#/channels/user~1signedup/messages/signed~1up' + components: + messages: + signed/up: + payload: + type: string + securitySchemes: + basic/auth: + type: userPassword + """; + + private const string V2Document = + """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + servers: + prod~eu: + url: example.com + protocol: kafka + security: + - basic/auth: [] + channels: + user/signedup: + servers: + - prod~eu + publish: + message: + $ref: '#/components/messages/signed~1up' + components: + messages: + signed/up: + payload: + type: string + securitySchemes: + basic/auth: + type: userPassword + """; + + [Test] + public void V3_Read_ResolvesEscapedReferencesToTheRootComponents() + { + var doc = Read(V3Document); + + var channel = doc.Channels["user/signedup"]; + doc.Operations["op"].Channel.Equals(channel).Should().BeTrue(); + channel.Servers.Single().Equals(doc.Servers["prod~eu"]).Should().BeTrue(); + channel.Messages["signed/up"].Equals(doc.Components.Messages["signed/up"]).Should().BeTrue(); + doc.Operations["op"].Messages.Single().Equals(channel.Messages["signed/up"]).Should().BeTrue(); + doc.Servers["prod~eu"].Security.Single().Type.Should().Be(SecuritySchemeType.UserPassword); + } + + [Test] + public void V3_RoundTrips() + { + var expected = + """ + asyncapi: 3.1.0 + info: + title: test + version: 1.0.0 + servers: + prod~eu: + host: example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/basic~1auth' + channels: + user/signedup: + address: user/signedup + messages: + signed/up: + $ref: '#/components/messages/signed~1up' + servers: + - $ref: '#/servers/prod~0eu' + operations: + op: + action: send + channel: + $ref: '#/channels/user~1signedup' + messages: + - $ref: '#/channels/user~1signedup/messages/signed~1up' + components: + messages: + signed/up: + payload: + schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0 + schema: + type: string + securitySchemes: + basic/auth: + type: userPassword + """; + + var actual = Read(V3Document).SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + Read(actual).SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V3_DowngradesToV2() + { + var expected = + """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + servers: + prod~eu: + url: example.com + protocol: kafka + security: + - basic/auth: [] + channels: + user/signedup: + servers: + - prod~eu + subscribe: + message: + $ref: '#/components/messages/signed~1up' + components: + messages: + signed/up: + payload: + type: string + schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0 + securitySchemes: + basic/auth: + type: userPassword + """; + + var actual = Read(V3Document).SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + Read(actual).SerializeAsYaml(AsyncApiVersion.AsyncApi2_0).Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V2_Read_ResolvesEscapedReferencesToTheRootComponents() + { + var doc = Read(V2Document); + + // 2.x channel keys are addresses, which the reader normalizes into 3.x keys. + var channel = doc.Channels["usersignedup"]; + doc.Operations.Values.Single().Channel.Equals(channel).Should().BeTrue(); + channel.Servers.Single().Equals(doc.Servers["prod~eu"]).Should().BeTrue(); + channel.Messages.Keys.Should().Equal("signed/up"); + channel.Messages["signed/up"].Equals(doc.Components.Messages["signed/up"]).Should().BeTrue(); + doc.Servers["prod~eu"].Security.Single().Type.Should().Be(SecuritySchemeType.UserPassword); + } + + [Test] + public void V2_RoundTrips() + { + var actual = Read(V2Document).SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(V2Document); + Read(actual).SerializeAsYaml(AsyncApiVersion.AsyncApi2_0).Should().BePlatformAgnosticEquivalentTo(V2Document); + } + + [Test] + public void V2_UpgradesToV3() + { + var expected = + """ + asyncapi: 3.1.0 + info: + title: test + version: 1.0.0 + servers: + prod~eu: + host: example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/basic~1auth' + channels: + usersignedup: + address: user/signedup + messages: + signed/up: + $ref: '#/components/messages/signed~1up' + servers: + - $ref: '#/servers/prod~0eu' + operations: + op: + action: receive + channel: + $ref: '#/channels/usersignedup' + messages: + - $ref: '#/channels/usersignedup/messages/signed~1up' + components: + messages: + signed/up: + payload: + type: string + securitySchemes: + basic/auth: + type: userPassword + """; + + var actual = Read(V2Document.Replace("publish:", "publish:\n operationId: op")).SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + + var upgraded = Read(actual); + upgraded.Operations["op"].Channel.Equals(upgraded.Channels["usersignedup"]).Should().BeTrue(); + upgraded.Operations["op"].Messages.Single().Equals(upgraded.Channels["usersignedup"].Messages["signed/up"]).Should().BeTrue(); + } + + [TestCase("user/signedup", "user~1signedup")] + [TestCase("user~signedup", "user~0signedup")] + public void V3_LocalChannelReference_WithEscapedKey_ResolvesTheRootChannel(string channelKey, string escapedKey) + { + var doc = Read(ChannelReferenceDocument(channelKey, escapedKey)); + + var channel = doc.Operations["op"].Channel; + channel.UnresolvedReference.Should().BeFalse(); + channel.Equals(doc.Channels[channelKey]).Should().BeTrue(); + } + + [Test] + public void V3_LocalChannelReference_WithUnescapedKey_DoesNotResolve() + { + // "/" separates reference tokens, so this points at channels -> user -> signedup. + var doc = new AsyncApiStringReader().Read(ChannelReferenceDocument("user/signedup", "user/signedup"), out var diagnostic); + + doc.Operations["op"].Channel.UnresolvedReference.Should().BeTrue(); + diagnostic.Errors.Should().Contain(e => e.Message == "Could not resolve reference fragment."); + } + + private static AsyncApiDocument Read(string yaml) + { + var doc = new AsyncApiStringReader().Read(yaml, out var diagnostic); + + // The keys fail the spec's key patterns; anything else means a reference did not resolve. + diagnostic.Errors.Should().OnlyContain(e => e.Message.Contains("MUST match the regular expression")); + return doc; + } + + private static string ChannelReferenceDocument(string channelKey, string referenceKey) + { + return + $""" + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + '{channelKey}': + address: user/signedup + operations: + op: + action: send + channel: + $ref: '#/channels/{referenceKey}' + """; + } + } +}