Skip to content

Commit 9149a76

Browse files
bieluclaude
andauthored
fix: percent-encode keys in local reference pointers (#62)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5a689cf commit 9149a76

10 files changed

Lines changed: 475 additions & 49 deletions

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ internal static partial class AsyncApiV2Deserializer
2121
private static string GetServerReferenceKey(ValueNode valueNode)
2222
{
2323
var stringValue = valueNode.GetScalarValue();
24-
return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + JsonPointer.EscapeToken(stringValue);
24+
return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + JsonPointer.EscapeUriFragmentToken(stringValue);
2525
}
2626

2727
private static readonly PatternFieldMap<AsyncApiChannel> ChannelPatternFields =
@@ -67,7 +67,7 @@ private static string NormalizeChannelPointer(string pointer)
6767
return pointer;
6868
}
6969

70-
return channelsPrefix + JsonPointer.EscapeToken(NormalizeChannelKey(parsed.Tokens[1]));
70+
return channelsPrefix + JsonPointer.EscapeUriFragmentToken(NormalizeChannelKey(parsed.Tokens[1]));
7171
}
7272

7373
public static string NormalizeChannelKey(string channelKey, ParseNode node = null)
@@ -101,7 +101,7 @@ private static void LoadV2Operation(ParseNode node, AsyncApiChannel instance, As
101101
var operation = LoadOperation(node);
102102
var operationKey = node.CheckMapNode("operation")?["operationId"]?.Value.GetScalarValue() ?? "anonymous-operation-" + Interlocked.Increment(ref node.Context.OperationCounter).ToString();
103103
operation.Action = action;
104-
operation.Channel = new AsyncApiChannelReference("#/channels/" + NormalizeChannelKey(instance.Address));
104+
operation.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeUriFragmentToken(NormalizeChannelKey(instance.Address)));
105105

106106
var globalOperations = node.Context.GetFromTempStorage<Dictionary<string, AsyncApiOperation>>(TempStorageKeys.Operations) ?? new Dictionary<string, AsyncApiOperation>();
107107

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiDocumentDeserializer.cs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,16 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum
127127
if (operation.Value.Channel != null)
128128
{
129129
var messages = context.GetFromTempStorage<Dictionary<string, AsyncApiMessageReference>>(TempStorageKeys.OperationMessageReferences, operation.Value);
130-
var operationChannelFragmentKey = operation.Value.Channel.Reference.Reference.Split('/').Last();
130+
var operationChannelFragmentKey = GetLastReferenceToken(operation.Value.Channel.Reference);
131131
var channel = document.Channels.FirstOrDefault(channel => channel.Key == operationChannelFragmentKey);
132132
if (channel.Value == null)
133133
{
134134
// it most likely came from a components channel, so the reference will be wrong.
135135
// Find the channel that references this operations channel, and move the reference.
136-
var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && reference.Reference.Reference.EndsWith(operationChannelFragmentKey));
136+
var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && GetLastReferenceToken(reference.Reference) == operationChannelFragmentKey);
137137
if (correctChannelReference.Key != null)
138138
{
139-
operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeToken(correctChannelReference.Key));
139+
operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeUriFragmentToken(correctChannelReference.Key));
140140
channel = correctChannelReference;
141141
}
142142
else
@@ -161,10 +161,20 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum
161161
foreach (var message in messages)
162162
{
163163
channel.Value.Messages.TryAdd(message.Key, message.Value);
164-
operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{JsonPointer.EscapeToken(channel.Key)}/messages/{JsonPointer.EscapeToken(message.Key)}"));
164+
operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{JsonPointer.EscapeUriFragmentToken(channel.Key)}/messages/{JsonPointer.EscapeUriFragmentToken(message.Key)}"));
165165
}
166166
}
167167
}
168168
}
169+
170+
/// <summary>
171+
/// Gets the unescaped last pointer token of a reference, so it can be compared with a map key.
172+
/// </summary>
173+
private static string GetLastReferenceToken(AsyncApiReference reference)
174+
{
175+
return reference.IsFragment && JsonPointer.TryFromUriFragment(reference.FragmentId, out var pointer) && pointer.Tokens.Length > 0
176+
? pointer.Tokens.Last()
177+
: reference.Reference.Split('/').Last();
178+
}
169179
}
170180
}

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiOperationDeserializer.cs‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@ private static KeyValuePair<string, AsyncApiMessage> GetMessage(ParseNode node)
4949
if (message is AsyncApiMessageReference reference)
5050
{
5151
// Use the unescaped last pointer token, so the key matches the referenced component's key.
52-
key = reference.Reference.IsFragment && JsonPointer.TryFromUriFragment(reference.Reference.FragmentId, out var pointer) && pointer.Tokens.Length > 0
53-
? pointer.Tokens.Last()
54-
: reference.Reference.Reference.Split('/').Last();
52+
key = GetLastReferenceToken(reference.Reference);
5553
}
5654
else if (messageNode["messageId"] != null)
5755
{
@@ -90,7 +88,7 @@ private static void LoadMessages(ParseNode n, AsyncApiOperation instance)
9088
{
9189
if (message.Value is not AsyncApiMessageReference messageReference)
9290
{
93-
var componentReference = "#/components/messages/" + JsonPointer.EscapeToken(message.Key);
91+
var componentReference = "#/components/messages/" + JsonPointer.EscapeUriFragmentToken(message.Key);
9492
componentMessages.Add(message.Key, message.Value);
9593
componentMessageReferences.Add(message.Key, new AsyncApiMessageReference(componentReference));
9694
continue;

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiSecurityRequirementDeserializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ private static AsyncApiSecuritySchemeReference LoadSecuritySchemeByReference(
3838
ParsingContext context,
3939
string schemeName)
4040
{
41-
var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + JsonPointer.EscapeToken(schemeName));
41+
var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + JsonPointer.EscapeUriFragmentToken(schemeName));
4242

4343
return securitySchemeObject;
4444
}

0 commit comments

Comments
 (0)