From 63fb61c62c43b7319d634ac494140e932ef38e08 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:15:21 +0100 Subject: [PATCH 1/2] fix: percent-encode keys in local reference pointers The workspace registered keys escaped with only "~0" / "~1", while names recovered from references were percent-decoded (RFC 6901 section 6). Keys containing "%" or characters that are percent-encoded in a URI fragment did not round-trip: - A 2.x server named "a%20b" was written back as "a b", and upgraded to a 3.x "#/servers/a%20b" reference that points at a server named "a b". - A 3.x "$ref: '#/servers/a%20b'" missed the workspace entry for "a b" and was resolved from the raw document into a separate copy of the server. The workspace now normalizes every location's fragment into one percent-encoded form, so encoded and unencoded references find the same entry, and registrations and the 2.x reader's references are built with JsonPointer.EscapeUriFragmentToken. Registry keys are now compared as strings instead of Uris. Uri equality ignores the fragment of absolute URIs, so "https://x/s.json#/A" and "https://x/s.json#/B" shared an entry and the second reference silently resolved to the first schema. Fixes #61 Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiChannelDeserializer.cs | 4 +- .../V2/AsyncApiDocumentDeserializer.cs | 18 +- .../V2/AsyncApiOperationDeserializer.cs | 6 +- ...AsyncApiSecurityRequirementDeserializer.cs | 2 +- src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs | 83 +++-- src/ByteBard.AsyncAPI/JsonPointer.cs | 19 ++ .../JsonPointer_Should.cs | 23 ++ .../Models/AsyncApiReference_Should.cs | 31 ++ .../PercentEncodedReferenceKeys_Should.cs | 310 ++++++++++++++++++ 9 files changed, 449 insertions(+), 47 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs index 6b95488..1b0567c 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 = @@ -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/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..0e52310 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs @@ -0,0 +1,310 @@ +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(); + } + + 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; + } + } +} From e0a976bd36c6cd9be2e732c2fa58b17b071e5b8d Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:12:54 +0100 Subject: [PATCH 2/2] fix: percent-encode 2.x channel references written by address AsyncApiChannelReference.SerializeV2 writes a reference to a root channel with the channel's address, escaped with EscapeToken only. A channel addressed "a%20b" was written as "#/channels/a%20b", which decodes to "a b" and no longer resolves. It now uses EscapeUriFragmentToken, as does the 2.x reader when it points such a reference at the normalized channel key. Co-Authored-By: Claude Opus 5.5 --- .../V2/AsyncApiChannelDeserializer.cs | 2 +- .../References/AsyncApiChannelReference.cs | 2 +- .../PercentEncodedReferenceKeys_Should.cs | 26 ++++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs index 1b0567c..9f6e5b8 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs @@ -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) 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/Models/PercentEncodedReferenceKeys_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs index 0e52310..13248bb 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/PercentEncodedReferenceKeys_Should.cs @@ -298,12 +298,36 @@ public void V2_UpgradesToV3() 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.Should().OnlyContain(e => e.Message.Contains("MUST match the regular expression")); + diagnostic.Errors.Where(e => !e.Message.Contains("MUST match the regular expression")).Should().BeEmpty(); return doc; } }