Skip to content

Commit 95326dd

Browse files
bieluclaude
andcommitted
fix: escape 2.x channel server names in server references
The 2.x reader built "#/servers/{name}" without escaping the name, while the writer decodes the pointer token. A literal name such as "a~1b" therefore came back as "a/b" after a read/write round trip. The reader now escapes the name, and the workspace registers document servers under the escaped pointer so the reference still resolves. Server names this needs are outside the spec's key pattern, so this only matters when validation is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c7e570e commit 95326dd

5 files changed

Lines changed: 86 additions & 4 deletions

File tree

‎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/AsyncApiWorkspace.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,31 @@ public static JsonPointer FromUriFragment(string fragment)
8383
return new JsonPointer(Uri.UnescapeDataString(fragment));
8484
}
8585

86+
/// <summary>
87+
/// Escapes a single reference token, so "~" becomes "~0" and "/" becomes "~1".
88+
/// </summary>
89+
/// <param name="token">The unescaped token.</param>
90+
public static string EscapeToken(string token)
91+
{
92+
return token.Replace("~", "~0").Replace("/", "~1");
93+
}
94+
95+
/// <summary>
96+
/// Unescapes a single reference token, so "~1" becomes "/" and "~0" becomes "~".
97+
/// Unlike parsing a pointer, this does not reject a "~" that is not part of an escape sequence.
98+
/// </summary>
99+
/// <param name="token">The escaped token.</param>
100+
public static string UnescapeToken(string token)
101+
{
102+
return token.Replace("~1", "/").Replace("~0", "~");
103+
}
104+
86105
/// <summary>
87106
/// Gets the JSON string representation of this JSON pointer.
88107
/// </summary>
89108
public override string ToString()
90109
{
91-
return string.Concat(this.Tokens.Select(token => "/" + token.Replace("~", "~0").Replace("/", "~1")));
110+
return string.Concat(this.Tokens.Select(token => "/" + EscapeToken(token)));
92111
}
93112

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ private static string GetV2ServerName(AsyncApiServerReference server)
181181
}
182182

183183
// The 2.x reader keeps the name unescaped, while a 3.x pointer escapes it, so decode the whole remainder as one token.
184-
return reference.FragmentId.Substring(serversPrefix.Length).Replace("~1", "/").Replace("~0", "~");
184+
return JsonPointer.UnescapeToken(reference.FragmentId.Substring(serversPrefix.Length));
185185
}
186186
}
187187
}

‎test/ByteBard.AsyncAPI.Tests/Models/AsyncApiChannel_Should.cs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using ByteBard.AsyncAPI.Models;
99
using ByteBard.AsyncAPI.Models.Interfaces;
1010
using ByteBard.AsyncAPI.Readers;
11+
using ByteBard.AsyncAPI.Validations;
1112
using NUnit.Framework;
1213

1314
public class AsyncApiChannel_Should : TestBase
@@ -251,6 +252,68 @@ public void V3_AsyncApiChannel_WithServers_Reserializes()
251252
reserialized.Should().BePlatformAgnosticEquivalentTo(yaml);
252253
}
253254

255+
[TestCase("a~1b", "#/servers/a~01b")]
256+
[TestCase("a/b", "#/servers/a~1b")]
257+
public void V2_AsyncApiChannel_WithServerNameNeedingEscape_Reserializes(string serverName, string expectedReference)
258+
{
259+
var yaml =
260+
$"""
261+
asyncapi: 2.6.0
262+
info:
263+
title: test spec
264+
version: 1.0.0
265+
servers:
266+
{serverName}:
267+
url: example.com
268+
protocol: kafka
269+
channels:
270+
mychannel:
271+
servers:
272+
- {serverName}
273+
""";
274+
275+
var document = new AsyncApiStringReader(new AsyncApiReaderSettings { RuleSet = ValidationRuleSet.GetEmptyRuleSet() }).Read(yaml, out var diag);
276+
277+
diag.Errors.Should().BeEmpty();
278+
var server = document.Channels.First().Value.Servers.Single();
279+
server.Reference.Reference.Should().Be(expectedReference);
280+
server.Host.Should().Be("example.com");
281+
282+
var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0);
283+
284+
reserialized.Should().BePlatformAgnosticEquivalentTo(yaml);
285+
}
286+
287+
[Test]
288+
public void V3_AsyncApiChannel_WithEscapedServerReference_ResolvesServer()
289+
{
290+
var yaml =
291+
"""
292+
asyncapi: 3.1.0
293+
info:
294+
title: test spec
295+
version: 1.0.0
296+
servers:
297+
a/b:
298+
host: example.com
299+
protocol: kafka
300+
channels:
301+
mychannel:
302+
servers:
303+
- $ref: '#/servers/a~1b'
304+
components: { }
305+
""";
306+
307+
var document = new AsyncApiStringReader(new AsyncApiReaderSettings { RuleSet = ValidationRuleSet.GetEmptyRuleSet() }).Read(yaml, out var diag);
308+
309+
diag.Errors.Should().BeEmpty();
310+
document.Channels.First().Value.Servers.Single().Host.Should().Be("example.com");
311+
312+
var reserialized = document.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
313+
314+
reserialized.Should().BePlatformAgnosticEquivalentTo(yaml);
315+
}
316+
254317
[Test]
255318
public void V3_AsyncApiChannel_WithServerReference_SerializesReference()
256319
{

0 commit comments

Comments
 (0)