diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs index 6b95488..9f6e5b8 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/" + JsonPointer.EscapeToken(stringValue); + return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + JsonPointer.EscapeUriFragmentToken(stringValue); } private static readonly PatternFieldMap ChannelPatternFields = @@ -67,7 +67,7 @@ private static string NormalizeChannelPointer(string pointer) return pointer; } - return channelsPrefix + JsonPointer.EscapeToken(NormalizeChannelKey(parsed.Tokens[1])); + return channelsPrefix + JsonPointer.EscapeUriFragmentToken(NormalizeChannelKey(parsed.Tokens[1])); } public static string NormalizeChannelKey(string channelKey, ParseNode node = null) @@ -101,7 +101,7 @@ private static void LoadV2Operation(ParseNode node, AsyncApiChannel instance, As var operation = LoadOperation(node); var operationKey = node.CheckMapNode("operation")?["operationId"]?.Value.GetScalarValue() ?? "anonymous-operation-" + Interlocked.Increment(ref node.Context.OperationCounter).ToString(); operation.Action = action; - operation.Channel = new AsyncApiChannelReference("#/channels/" + NormalizeChannelKey(instance.Address)); + operation.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeUriFragmentToken(NormalizeChannelKey(instance.Address))); var globalOperations = node.Context.GetFromTempStorage>(TempStorageKeys.Operations) ?? new Dictionary(); diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs index 0ebe468..3331b14 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs @@ -127,16 +127,16 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum if (operation.Value.Channel != null) { var messages = context.GetFromTempStorage>(TempStorageKeys.OperationMessageReferences, operation.Value); - var operationChannelFragmentKey = operation.Value.Channel.Reference.Reference.Split('/').Last(); + var operationChannelFragmentKey = GetLastReferenceToken(operation.Value.Channel.Reference); var channel = document.Channels.FirstOrDefault(channel => channel.Key == operationChannelFragmentKey); if (channel.Value == null) { // it most likely came from a components channel, so the reference will be wrong. // Find the channel that references this operations channel, and move the reference. - var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && reference.Reference.Reference.EndsWith(operationChannelFragmentKey)); + var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && GetLastReferenceToken(reference.Reference) == operationChannelFragmentKey); if (correctChannelReference.Key != null) { - operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeToken(correctChannelReference.Key)); + operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeUriFragmentToken(correctChannelReference.Key)); channel = correctChannelReference; } else @@ -161,10 +161,20 @@ 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/{JsonPointer.EscapeToken(channel.Key)}/messages/{JsonPointer.EscapeToken(message.Key)}")); + operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{JsonPointer.EscapeUriFragmentToken(channel.Key)}/messages/{JsonPointer.EscapeUriFragmentToken(message.Key)}")); } } } } + + /// + /// Gets the unescaped last pointer token of a reference, so it can be compared with a map key. + /// + private static string GetLastReferenceToken(AsyncApiReference reference) + { + return reference.IsFragment && JsonPointer.TryFromUriFragment(reference.FragmentId, out var pointer) && pointer.Tokens.Length > 0 + ? pointer.Tokens.Last() + : reference.Reference.Split('/').Last(); + } } } diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs index 05c8734..322d88e 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs @@ -49,9 +49,7 @@ private static KeyValuePair GetMessage(ParseNode node) if (message is AsyncApiMessageReference reference) { // 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(); + key = GetLastReferenceToken(reference.Reference); } else if (messageNode["messageId"] != null) { @@ -90,7 +88,7 @@ private static void LoadMessages(ParseNode n, AsyncApiOperation instance) { if (message.Value is not AsyncApiMessageReference messageReference) { - var componentReference = "#/components/messages/" + JsonPointer.EscapeToken(message.Key); + var componentReference = "#/components/messages/" + JsonPointer.EscapeUriFragmentToken(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 2efa3e1..1a523b1 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/" + JsonPointer.EscapeToken(schemeName)); + var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + JsonPointer.EscapeUriFragmentToken(schemeName)); return securitySchemeObject; } diff --git a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs index 7756340..4a59105 100644 --- a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs +++ b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs @@ -9,8 +9,8 @@ public class AsyncApiWorkspace { - private readonly Dictionary artifactsRegistry = new(); - private readonly Dictionary resolvedReferenceRegistry = new(); + private readonly Dictionary artifactsRegistry = new(StringComparer.Ordinal); + private readonly Dictionary resolvedReferenceRegistry = new(StringComparer.Ordinal); public AsyncApiDocument RootDocument { get; private set; } @@ -44,56 +44,56 @@ public void RegisterComponents(AsyncApiDocument document) // Register Schema foreach (var item in document.Components.Schemas) { - location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Parameters foreach (var item in document.Components.Parameters) { - location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Channels foreach (var item in document.Components.Channels) { - location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Operations foreach (var item in document.Components.Operations) { - location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Servers foreach (var item in document.Components.Servers) { - location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ServerVariables foreach (var item in document.Components.ServerVariables) { - location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Messages foreach (var item in document.Components.Messages) { - location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register SecuritySchemes foreach (var item in document.Components.SecuritySchemes) { - location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(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() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register CorrelationIds foreach (var item in document.Components.CorrelationIds) { - location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Replies foreach (var item in document.Components.Replies) { - location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ReplyAddresses foreach (var item in document.Components.ReplyAddresses) { - location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ExternalDocs foreach (var item in document.Components.ExternalDocs) { - location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register Tags foreach (var item in document.Components.Tags) { - location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register OperationTraits foreach (var item in document.Components.OperationTraits) { - location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register MessageTraits foreach (var item in document.Components.MessageTraits) { - location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ServerBindings foreach (var item in document.Components.ServerBindings) { - location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register ChannelBindings foreach (var item in document.Components.ChannelBindings) { - location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register OperationBindings foreach (var item in document.Components.OperationBindings) { - location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } // Register MessageBindings foreach (var item in document.Components.MessageBindings) { - location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key); + location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + JsonPointer.EscapeUriFragmentToken(item.Key); this.RegisterComponent(location, item.Value); } @@ -203,12 +203,12 @@ public void RegisterComponents(AsyncApiDocument document) continue; } - location = channelBaseUri + JsonPointer.EscapeToken(channel.Key); + location = channelBaseUri + JsonPointer.EscapeUriFragmentToken(channel.Key); this.RegisterComponent(location, registerableChannelValue); foreach (var message in registerableChannelValue.Messages) { - this.RegisterComponent(location + "/messages/" + JsonPointer.EscapeToken(message.Key), message.Value); + this.RegisterComponent(location + "/messages/" + JsonPointer.EscapeUriFragmentToken(message.Key), message.Value); } } @@ -226,28 +226,28 @@ public void RegisterComponents(AsyncApiDocument document) registerableServerValue = this.ResolveReference(serverReference.Reference); } - location = serverBaseUri + JsonPointer.EscapeToken(server.Key); + location = serverBaseUri + JsonPointer.EscapeUriFragmentToken(server.Key); this.RegisterComponent(location, registerableServerValue); } } public bool RegisterComponent(string location, T component) { - var uri = this.ToLocationUrl(location); + var key = NormalizeLocation(location); if (component is IAsyncApiSerializable referenceable) { - if (!this.resolvedReferenceRegistry.ContainsKey(uri)) + if (!this.resolvedReferenceRegistry.ContainsKey(key)) { - this.resolvedReferenceRegistry[uri] = referenceable; + this.resolvedReferenceRegistry[key] = referenceable; return true; } } if (component is Stream stream) { - if (!this.artifactsRegistry.ContainsKey(uri)) + if (!this.artifactsRegistry.ContainsKey(key)) { - this.artifactsRegistry[uri] = stream; + this.artifactsRegistry[key] = stream; } return true; @@ -258,7 +258,7 @@ public bool RegisterComponent(string location, T component) public bool Contains(string location) { - var key = this.ToLocationUrl(location); + var key = NormalizeLocation(location); return this.resolvedReferenceRegistry.ContainsKey(key) || this.artifactsRegistry.ContainsKey(key); } @@ -271,13 +271,13 @@ public T ResolveReference(AsyncApiReference reference) public T ResolveReference(string location) where T : class { - var uri = this.ToLocationUrl(location); - if (this.resolvedReferenceRegistry.TryGetValue(uri, out var referenceableValue)) + var key = NormalizeLocation(location); + if (this.resolvedReferenceRegistry.TryGetValue(key, out var referenceableValue)) { return referenceableValue as T; } - if (this.artifactsRegistry.TryGetValue(uri, out var stream)) + if (this.artifactsRegistry.TryGetValue(key, out var stream)) { stream.Position = 0; return (T)(object)stream; @@ -286,9 +286,20 @@ public T ResolveReference(string location) return default; } - private Uri ToLocationUrl(string location) + /// + /// Rewrites the JSON pointer fragment of a location into one percent-encoded form (RFC 6901 section 6), so encoded + /// and unencoded references to the same key share a registry entry, e.g. #/servers/a b and #/servers/a%20b. + /// Locations are compared as strings, as equality ignores the fragment of absolute URIs. + /// + private static string NormalizeLocation(string location) { - return new(location, UriKind.RelativeOrAbsolute); + var fragmentStart = location.IndexOf('#'); + if (fragmentStart < 0 || !JsonPointer.TryFromUriFragment(location.Substring(fragmentStart + 1), out var pointer)) + { + return location; + } + + return location.Substring(0, fragmentStart + 1) + pointer.ToUriFragment(); } public void SetRootDocument(AsyncApiDocument doc) diff --git a/src/ByteBard.AsyncAPI/JsonPointer.cs b/src/ByteBard.AsyncAPI/JsonPointer.cs index 0450c7e..ab5f420 100644 --- a/src/ByteBard.AsyncAPI/JsonPointer.cs +++ b/src/ByteBard.AsyncAPI/JsonPointer.cs @@ -119,6 +119,25 @@ public static string EscapeToken(string token) return token.Replace("~", "~0").Replace("/", "~1"); } + /// + /// Escapes a single reference token for a URI fragment identifier (RFC 6901 section 6), so "~" becomes "~0", + /// "/" becomes "~1", and the result is percent-encoded, e.g. "a b" becomes "a%20b" and "a%20b" becomes "a%2520b". + /// + /// The unescaped token. + public static string EscapeUriFragmentToken(string token) + { + return Uri.EscapeDataString(EscapeToken(token)); + } + + /// + /// Gets the URI fragment identifier representation of this JSON pointer (RFC 6901 section 6), without the leading #. + /// This is the reverse of . + /// + public string ToUriFragment() + { + return string.Concat(this.Tokens.Select(token => "/" + EscapeUriFragmentToken(token))); + } + /// /// Finds the node this pointer references. /// diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs index 56080e8..ceca5c8 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs @@ -64,7 +64,7 @@ public override void SerializeV2(IAsyncApiWriter writer) // 2.x keys root channels by address instead of by key. A target the document does not write // keeps its own reference, so it can be found dangling and written inline. writer.WriteStartObject(); - writer.WriteOptionalProperty(AsyncApiConstants.DollarRef, "#/channels/" + JsonPointer.EscapeToken(this.Target.Address)); + writer.WriteOptionalProperty(AsyncApiConstants.DollarRef, "#/channels/" + JsonPointer.EscapeUriFragmentToken(this.Target.Address)); writer.WriteEndObject(); return; } diff --git a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs index 954761b..c0f7783 100644 --- a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs @@ -86,6 +86,29 @@ public void EscapeToken_EscapesTildeBeforeSlash(string token, string expected) new JsonPointer("/" + expected).Tokens.Should().Equal(token); } + [TestCase("plain", "plain")] + [TestCase("a/b", "a~1b")] + [TestCase("a~b", "a~0b")] + [TestCase("a b", "a%20b")] + [TestCase("a%20b", "a%2520b")] + [TestCase("50%", "50%25")] + public void EscapeUriFragmentToken_EscapesAndPercentEncodes(string token, string expected) + { + JsonPointer.EscapeUriFragmentToken(token).Should().Be(expected); + JsonPointer.FromUriFragment("/" + expected).Tokens.Should().Equal(token); + } + + [TestCase("/servers/a b", "/servers/a%20b")] + [TestCase("/servers/a%20b", "/servers/a%20b")] + [TestCase("/servers/a%2520b", "/servers/a%2520b")] + [TestCase("/channels/user~1signedup", "/channels/user~1signedup")] + [TestCase("/channels/user%7E1signedup", "/channels/user~1signedup")] + [TestCase("", "")] + public void ToUriFragment_IsTheReverseOfFromUriFragment(string fragment, string expected) + { + JsonPointer.FromUriFragment(fragment).ToUriFragment().Should().Be(expected); + } + [Test] public void Unescape_DecodesTilde1BeforeTilde0() { diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs index f68a4ef..5d29f7f 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -214,6 +214,37 @@ public void V3_ExternalPayloadReference_WithoutFragment_ResolvesPayload() doc.Channels["workspace"].Messages["message"].Payload.Schema.As().Type.Should().Be(SchemaType.Boolean); } + // Uri equality ignores the fragment of absolute URIs, so the workspace must not treat these as one reference. + [Test] + public void AbsoluteExternalReferences_ToTheSameResource_ResolveTheirOwnFragments() + { + var yaml = + """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + components: + schemas: + a: + $ref: 'https://example.com/schemas.json#/A' + b: + $ref: 'https://example.com/schemas.json#/B' + """; + + var settings = new AsyncApiReaderSettings + { + ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences, + ExternalReferenceLoader = new MockStringLoader("""{ "A": { "type": "string" }, "B": { "type": "integer" } }"""), + }; + + var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + doc.Components.Schemas["a"].Schema.As().Type.Should().Be(SchemaType.String); + doc.Components.Schemas["b"].Schema.As().Type.Should().Be(SchemaType.Integer); + } + // A local payload reference that is not a registered component is resolved by falling back to the whole document. [TestCase("2.6.0")] [TestCase("3.0.0")] diff --git a/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs new file mode 100644 index 0000000..13248bb --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs @@ -0,0 +1,334 @@ +namespace ByteBard.AsyncAPI.Tests +{ + using System.Linq; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using FluentAssertions; + using NUnit.Framework; + + /// + /// Reference pointers are URI fragments, so keys are percent-encoded in them (RFC 6901 section 6): + /// "prod eu" is referenced as "prod%20eu", and a key that is literally "prod%20eu" as "prod%2520eu". + /// Such keys fail the spec's key patterns, but the documents still read, write, upgrade and downgrade. + /// + public class PercentEncodedReferenceKeys_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%20auth' + prod%20eu: + host: example.org + protocol: kafka + channels: + user signedup: + address: user/signedup + servers: + - $ref: '#/servers/prod%20eu' + - $ref: '#/servers/prod%2520eu' + messages: + signed up: + $ref: '#/components/messages/signed%20up' + operations: + op: + action: send + channel: + $ref: '#/channels/user%20signedup' + messages: + - $ref: '#/channels/user%20signedup/messages/signed%20up' + 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: [] + prod%20eu: + url: example.org + protocol: kafka + channels: + user/signedup: + servers: + - prod eu + - prod%20eu + publish: + message: + $ref: '#/components/messages/signed%20up' + components: + messages: + signed up: + payload: + type: string + securitySchemes: + basic auth: + type: userPassword + """; + + [Test] + public void V3_Read_ResolvesEncodedReferencesToTheRootComponents() + { + var doc = Read(V3Document); + + var channel = doc.Channels["user signedup"]; + doc.Operations["op"].Channel.Equals(channel).Should().BeTrue(); + channel.Servers[0].Equals(doc.Servers["prod eu"]).Should().BeTrue(); + channel.Servers[1].Equals(doc.Servers["prod%20eu"]).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); + } + + [TestCase("prod%20eu")] + [TestCase("prod eu")] + public void V3_LocalServerReference_ResolvesTheRootServer_WhetherOrNotItIsEncoded(string referenceKey) + { + var doc = Read( + $""" + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + servers: + prod eu: + host: example.com + protocol: kafka + channels: + test: + servers: + - $ref: '#/servers/{referenceKey}' + """); + + // Changes made through the root server must show through the reference, so it must not resolve to a copy. + doc.Servers["prod eu"].Host = "changed.example.com"; + doc.Channels["test"].Servers.Single().Host.Should().Be("changed.example.com"); + } + + [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%20auth' + prod%20eu: + host: example.org + protocol: kafka + channels: + user signedup: + address: user/signedup + messages: + signed up: + $ref: '#/components/messages/signed%20up' + servers: + - $ref: '#/servers/prod%20eu' + - $ref: '#/servers/prod%2520eu' + operations: + op: + action: send + channel: + $ref: '#/channels/user%20signedup' + messages: + - $ref: '#/channels/user%20signedup/messages/signed%20up' + 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: [] + prod%20eu: + url: example.org + protocol: kafka + channels: + user/signedup: + servers: + - prod eu + - prod%20eu + subscribe: + message: + $ref: '#/components/messages/signed%20up' + 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_ResolvesServerNamesToTheRootServers() + { + var doc = Read(V2Document); + + // 2.x channel keys are addresses, which the reader normalizes into 3.x keys. + var channel = doc.Channels["usersignedup"]; + channel.Servers[0].Equals(doc.Servers["prod eu"]).Should().BeTrue(); + channel.Servers[1].Equals(doc.Servers["prod%20eu"]).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%20auth' + prod%20eu: + host: example.org + protocol: kafka + channels: + usersignedup: + address: user/signedup + messages: + signed up: + $ref: '#/components/messages/signed%20up' + servers: + - $ref: '#/servers/prod%20eu' + - $ref: '#/servers/prod%2520eu' + operations: + op: + action: receive + channel: + $ref: '#/channels/usersignedup' + messages: + - $ref: '#/channels/usersignedup/messages/signed%20up' + 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.Channels["usersignedup"].Servers[0].Equals(upgraded.Servers["prod eu"]).Should().BeTrue(); + upgraded.Channels["usersignedup"].Servers[1].Equals(upgraded.Servers["prod%20eu"]).Should().BeTrue(); + upgraded.Operations["op"].Messages.Single().Equals(upgraded.Channels["usersignedup"].Messages["signed up"]).Should().BeTrue(); + } + + // 2.x keys channels by address, so a reference to a root channel is written with its encoded address. + [TestCase("a b", "a%20b")] + [TestCase("a%20b", "a%2520b")] + public void V2_ChannelReference_RoundTripsByEncodedAddress(string address, string encodedAddress) + { + var yaml = + $""" + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + channels: + {address}: + description: target + other: + $ref: '#/channels/{encodedAddress}' + """; + + var actual = Read(yaml).SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(yaml); + Read(actual).Channels["other"].Description.Should().Be("target"); + } + + 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.Where(e => !e.Message.Contains("MUST match the regular expression")).Should().BeEmpty(); + return doc; + } + } +}