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}' + """; + } + } +}