Skip to content

Commit ff96a92

Browse files
authored
fix!: escape keys in local reference pointers (#60)
Keys containing "/" or "~" were registered and referenced unescaped, so a spec-correct pointer such as "#/channels/user~1signedup" missed the workspace and was resolved from the raw document into a separate copy of the channel. Unescaped pointers only resolved because both sides skipped escaping. The workspace now registers every component, channel, channel message and server under its escaped pointer, and the 2.x reader escapes the keys of every reference it builds. Names recovered from references (2.x message keys, 2.x channel servers and security requirement names) are taken from the parsed pointer, so they come back unescaped. This keeps 2.x and 3.x reading, writing, upgrading and downgrading intact for such keys. BREAKING CHANGE: a local reference to a key containing "/" or "~" must escape it as "~1" or "~0" (RFC 6901). The unescaped form no longer resolves.
1 parent 7ed7e04 commit ff96a92

10 files changed

Lines changed: 401 additions & 32 deletions

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

Lines changed: 1 addition & 1 deletion
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/" + stringValue;
24+
return stringValue.StartsWith("#/servers/") ? stringValue : "#/servers/" + JsonPointer.EscapeToken(stringValue);
2525
}
2626

2727
private static readonly PatternFieldMap<AsyncApiChannel> ChannelPatternFields =

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ private static void SetOperations(ParsingContext context, AsyncApiDocument docum
136136
var correctChannelReference = document.Channels.FirstOrDefault(channel => channel.Value is AsyncApiChannelReference reference && reference.Reference.Reference.EndsWith(operationChannelFragmentKey));
137137
if (correctChannelReference.Key != null)
138138
{
139-
operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + correctChannelReference.Key);
139+
operation.Value.Channel = new AsyncApiChannelReference("#/channels/" + JsonPointer.EscapeToken(correctChannelReference.Key));
140140
channel = correctChannelReference;
141141
}
142142
else
@@ -161,7 +161,7 @@ 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/{channel.Key}/messages/{message.Key}"));
164+
operation.Value.Messages.Add(new AsyncApiMessageReference($"#/channels/{JsonPointer.EscapeToken(channel.Key)}/messages/{JsonPointer.EscapeToken(message.Key)}"));
165165
}
166166
}
167167
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ private static KeyValuePair<string, AsyncApiMessage> GetMessage(ParseNode node)
4848
var message = LoadMessage(node);
4949
if (message is AsyncApiMessageReference reference)
5050
{
51-
key = reference.Reference.Reference.Split('/').Last();
51+
// 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();
5255
}
5356
else if (messageNode["messageId"] != null)
5457
{
@@ -87,7 +90,7 @@ private static void LoadMessages(ParseNode n, AsyncApiOperation instance)
8790
{
8891
if (message.Value is not AsyncApiMessageReference messageReference)
8992
{
90-
var componentReference = "#/components/messages/" + message.Key;
93+
var componentReference = "#/components/messages/" + JsonPointer.EscapeToken(message.Key);
9194
componentMessages.Add(message.Key, message.Value);
9295
componentMessageReferences.Add(message.Key, new AsyncApiMessageReference(componentReference));
9396
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/" + schemeName);
41+
var securitySchemeObject = new AsyncApiSecuritySchemeReference("#/components/securitySchemes/" + JsonPointer.EscapeToken(schemeName));
4242

4343
return securitySchemeObject;
4444
}

‎src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,141 +44,141 @@ public void RegisterComponents(AsyncApiDocument document)
4444
// Register Schema
4545
foreach (var item in document.Components.Schemas)
4646
{
47-
location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + item.Key;
47+
location = componentsBaseUri + ReferenceType.Schema.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
4848
this.RegisterComponent(location, item.Value);
4949
}
5050

5151
// Register Parameters
5252
foreach (var item in document.Components.Parameters)
5353
{
54-
location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + item.Key;
54+
location = componentsBaseUri + ReferenceType.Parameter.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
5555
this.RegisterComponent(location, item.Value);
5656
}
5757

5858
// Register Channels
5959
foreach (var item in document.Components.Channels)
6060
{
61-
location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + item.Key;
61+
location = componentsBaseUri + ReferenceType.Channel.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
6262
this.RegisterComponent(location, item.Value);
6363
}
6464

6565
// Register Operations
6666
foreach (var item in document.Components.Operations)
6767
{
68-
location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + item.Key;
68+
location = componentsBaseUri + ReferenceType.Operation.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
6969
this.RegisterComponent(location, item.Value);
7070
}
7171

7272
// Register Servers
7373
foreach (var item in document.Components.Servers)
7474
{
75-
location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + item.Key;
75+
location = componentsBaseUri + ReferenceType.Server.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
7676
this.RegisterComponent(location, item.Value);
7777
}
7878

7979
// Register ServerVariables
8080
foreach (var item in document.Components.ServerVariables)
8181
{
82-
location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + item.Key;
82+
location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
8383
this.RegisterComponent(location, item.Value);
8484
}
8585

8686
// Register Messages
8787
foreach (var item in document.Components.Messages)
8888
{
89-
location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + item.Key;
89+
location = componentsBaseUri + ReferenceType.Message.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
9090
this.RegisterComponent(location, item.Value);
9191
}
9292

9393
// Register SecuritySchemes
9494
foreach (var item in document.Components.SecuritySchemes)
9595
{
96-
location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + item.Key;
96+
location = componentsBaseUri + ReferenceType.SecurityScheme.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
9797
this.RegisterComponent(location, item.Value);
9898
this.RegisterComponent(item.Key, item.Value);
9999
}
100100

101101
// Register Server Variables
102102
foreach (var item in document.Components.ServerVariables)
103103
{
104-
location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + item.Key;
104+
location = componentsBaseUri + ReferenceType.ServerVariable.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
105105
this.RegisterComponent(location, item.Value);
106106
}
107107

108108
// Register CorrelationIds
109109
foreach (var item in document.Components.CorrelationIds)
110110
{
111-
location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + item.Key;
111+
location = componentsBaseUri + ReferenceType.CorrelationId.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
112112
this.RegisterComponent(location, item.Value);
113113
}
114114

115115
// Register Replies
116116
foreach (var item in document.Components.Replies)
117117
{
118-
location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + item.Key;
118+
location = componentsBaseUri + ReferenceType.OperationReply.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
119119
this.RegisterComponent(location, item.Value);
120120
}
121121

122122
// Register ReplyAddresses
123123
foreach (var item in document.Components.ReplyAddresses)
124124
{
125-
location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + item.Key;
125+
location = componentsBaseUri + ReferenceType.OperationReplyAddress.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
126126
this.RegisterComponent(location, item.Value);
127127
}
128128

129129
// Register ExternalDocs
130130
foreach (var item in document.Components.ExternalDocs)
131131
{
132-
location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + item.Key;
132+
location = componentsBaseUri + ReferenceType.ExternalDocs.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
133133
this.RegisterComponent(location, item.Value);
134134
}
135135

136136
// Register Tags
137137
foreach (var item in document.Components.Tags)
138138
{
139-
location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + item.Key;
139+
location = componentsBaseUri + ReferenceType.Tag.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
140140
this.RegisterComponent(location, item.Value);
141141
}
142142

143143
// Register OperationTraits
144144
foreach (var item in document.Components.OperationTraits)
145145
{
146-
location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + item.Key;
146+
location = componentsBaseUri + ReferenceType.OperationTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
147147
this.RegisterComponent(location, item.Value);
148148
}
149149

150150
// Register MessageTraits
151151
foreach (var item in document.Components.MessageTraits)
152152
{
153-
location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + item.Key;
153+
location = componentsBaseUri + ReferenceType.MessageTrait.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
154154
this.RegisterComponent(location, item.Value);
155155
}
156156

157157
// Register ServerBindings
158158
foreach (var item in document.Components.ServerBindings)
159159
{
160-
location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + item.Key;
160+
location = componentsBaseUri + ReferenceType.ServerBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
161161
this.RegisterComponent(location, item.Value);
162162
}
163163

164164
// Register ChannelBindings
165165
foreach (var item in document.Components.ChannelBindings)
166166
{
167-
location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + item.Key;
167+
location = componentsBaseUri + ReferenceType.ChannelBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
168168
this.RegisterComponent(location, item.Value);
169169
}
170170

171171
// Register OperationBindings
172172
foreach (var item in document.Components.OperationBindings)
173173
{
174-
location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + item.Key;
174+
location = componentsBaseUri + ReferenceType.OperationBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
175175
this.RegisterComponent(location, item.Value);
176176
}
177177

178178
// Register MessageBindings
179179
foreach (var item in document.Components.MessageBindings)
180180
{
181-
location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + item.Key;
181+
location = componentsBaseUri + ReferenceType.MessageBindings.GetDisplayName() + "/" + JsonPointer.EscapeToken(item.Key);
182182
this.RegisterComponent(location, item.Value);
183183
}
184184

@@ -196,12 +196,12 @@ public void RegisterComponents(AsyncApiDocument document)
196196
registerableChannelValue = this.ResolveReference<AsyncApiChannel>(reference.Reference);
197197
}
198198

199-
location = channelBaseUri + channel.Key;
199+
location = channelBaseUri + JsonPointer.EscapeToken(channel.Key);
200200
this.RegisterComponent(location, registerableChannelValue);
201201

202202
foreach (var message in registerableChannelValue.Messages)
203203
{
204-
this.RegisterComponent(location + "/messages/" + message.Key, message.Value);
204+
this.RegisterComponent(location + "/messages/" + JsonPointer.EscapeToken(message.Key), message.Value);
205205
}
206206
}
207207

@@ -219,7 +219,7 @@ public void RegisterComponents(AsyncApiDocument document)
219219
registerableServerValue = this.ResolveReference<AsyncApiServer>(serverReference.Reference);
220220
}
221221

222-
location = serverBaseUri + server.Key;
222+
location = serverBaseUri + JsonPointer.EscapeToken(server.Key);
223223
this.RegisterComponent(location, registerableServerValue);
224224
}
225225
}

‎src/ByteBard.AsyncAPI/JsonPointer.cs‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,46 @@ public static JsonPointer FromUriFragment(string fragment)
8383
return new JsonPointer(Uri.UnescapeDataString(fragment));
8484
}
8585

86+
/// <summary>
87+
/// Tries to create a pointer from its URI fragment identifier representation (RFC 6901 section 6).
88+
/// </summary>
89+
/// <param name="fragment">The fragment, without the leading <c>#</c>.</param>
90+
/// <param name="pointer">The pointer, or <c>null</c> when the fragment is not a valid JSON pointer.</param>
91+
/// <returns><c>true</c> when the fragment is a valid JSON pointer.</returns>
92+
public static bool TryFromUriFragment(string fragment, out JsonPointer pointer)
93+
{
94+
pointer = null;
95+
if (fragment == null)
96+
{
97+
return false;
98+
}
99+
100+
try
101+
{
102+
pointer = FromUriFragment(fragment);
103+
return true;
104+
}
105+
catch (AsyncApiException)
106+
{
107+
return false;
108+
}
109+
}
110+
111+
/// <summary>
112+
/// Escapes a single reference token, so "~" becomes "~0" and "/" becomes "~1".
113+
/// </summary>
114+
/// <param name="token">The unescaped token.</param>
115+
public static string EscapeToken(string token)
116+
{
117+
return token.Replace("~", "~0").Replace("/", "~1");
118+
}
119+
86120
/// <summary>
87121
/// Gets the JSON string representation of this JSON pointer.
88122
/// </summary>
89123
public override string ToString()
90124
{
91-
return string.Concat(this.Tokens.Select(token => "/" + token.Replace("~", "~0").Replace("/", "~1")));
125+
return string.Concat(this.Tokens.Select(token => "/" + EscapeToken(token)));
92126
}
93127

94128
private static string Unescape(string token, string pointer)

‎src/ByteBard.AsyncAPI/Models/AsyncApiChannel.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public virtual void SerializeV2(IAsyncApiWriter writer)
8484
writer.WriteOptionalProperty(AsyncApiConstants.Description, this.Description);
8585

8686
// servers
87-
writer.WriteOptionalCollection(AsyncApiConstants.Servers, this.Servers.Select(s => s.Reference.FragmentId).ToList(), (w, s) => w.WriteValue(s));
87+
writer.WriteOptionalCollection(AsyncApiConstants.Servers, this.Servers.Select(GetV2ServerName).ToList(), (w, s) => w.WriteValue(s));
8888

8989
var operations = writer.Workspace.RootDocument?.Operations.Values.Where(operation => CheckOperationChannel(operation, writer)).ToList();
9090

@@ -166,5 +166,23 @@ private bool CheckOperationChannel(AsyncApiOperation operation, IAsyncApiWriter
166166
operation.Channel.Reference.Workspace = writer.Workspace;
167167
return operation.Channel.Equals(this);
168168
}
169+
170+
/// <summary>
171+
/// AsyncAPI 2.x lists channel servers by name, so a local "#/servers/{name}" pointer is written as "{name}".
172+
/// Any other reference is written as is, since reducing it to a name would point it at a local server instead.
173+
/// </summary>
174+
private static string GetV2ServerName(AsyncApiServerReference server)
175+
{
176+
var reference = server.Reference;
177+
if (!reference.IsExternal
178+
&& JsonPointer.TryFromUriFragment(reference.FragmentId, out var pointer)
179+
&& pointer.Tokens.Length == 2
180+
&& pointer.Tokens[0] == AsyncApiConstants.Servers)
181+
{
182+
return pointer.Tokens[1];
183+
}
184+
185+
return reference.Reference;
186+
}
169187
}
170188
}

‎src/ByteBard.AsyncAPI/Models/References/AsyncApiSecuritySchemeReference.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ public void SerializeAsSecurityRequirement(IAsyncApiWriter writer)
8888
this.Reference.Workspace = writer.Workspace;
8989

9090
writer.WriteStartObject();
91-
writer.WritePropertyName(this.Reference.FragmentId.Split('/').Last());
91+
// 2.x security requirements list schemes by name: the unescaped last pointer token, or the bare name.
92+
var fragmentId = this.Reference.FragmentId;
93+
writer.WritePropertyName(JsonPointer.TryFromUriFragment(fragmentId, out var pointer) && pointer.Tokens.Length > 0 ? pointer.Tokens.Last() : fragmentId);
9294
writer.WriteStartArray();
9395

9496
if (this.Scopes.Any())

‎test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ public void StringRepresentation_IsNotPercentDecoded()
7777
new JsonPointer("/a%20b").Find(json).Should().BeNull();
7878
}
7979

80+
[TestCase("plain", "plain")]
81+
[TestCase("a/b", "a~1b")]
82+
[TestCase("a~b", "a~0b")]
83+
[TestCase("~1", "~01")]
84+
public void EscapeToken_EscapesTildeBeforeSlash(string token, string expected)
85+
{
86+
JsonPointer.EscapeToken(token).Should().Be(expected);
87+
new JsonPointer("/" + expected).Tokens.Should().Equal(token);
88+
}
89+
8090
[Test]
8191
public void Unescape_DecodesTilde1BeforeTilde0()
8292
{

0 commit comments

Comments
 (0)