Skip to content

Commit 2d0c046

Browse files
authored
fix: align identifier validation with spec constraints (#68)
* fix: align identifier validation with spec constraints Use URL-safe ASCII identifier keys (letters, digits, dots, underscores, and hyphens) as the library baseline, with stricter rules wherever the specification requires them. - Reject dots in server keys. - Restrict component keys to ASCII. - Preserve the existing channel-key restriction. - Correct channel-address expression detection to include 0 and require nonempty names. - Add regression coverage for validation and parameter serialization. Compatibility: dotted server keys and Unicode component keys now produce validation errors, while their values remain preserved in the model. * fix(validation): reject server keys containing newlines Use the strict \z end-of-string anchor for server key validation so keys with a trailing newline cannot pass through the $ anchor. Add regression cases for trailing LF, CRLF, and embedded newlines in AsyncAPI 2.6.0 and 3.0.0. Escape carriage returns and newlines in double-quoted YAML keys so the tests preserve these characters for validation.
1 parent cc1e7a5 commit 2d0c046

5 files changed

Lines changed: 177 additions & 5 deletions

File tree

‎src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
public static class AsyncApiExtensions
66
{
7-
private static Regex channelAddressExpressionRegex = new Regex("{[a-zA-Z1-9_-]*}");
7+
private static Regex channelAddressExpressionRegex = new Regex(@"\{[A-Za-z0-9_-]+\}");
88

99
public static bool IsChannelAddressExpression(this string address)
1010
{

‎src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ public static class AsyncApiComponentsRules
1111
/// <summary>
1212
/// The key regex.
1313
/// </summary>
14-
public static Regex KeyRegex = new Regex(@"^[\w\d\.\-_]+$");
14+
public static Regex KeyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$");
1515

1616
/// <summary>
1717
/// All the fixed fields declared above are objects
18-
/// that MUST use keys that match the regular expression: ^[\w\d\.\-_]+$.
18+
/// that MUST use keys that match the regular expression: ^[a-zA-Z0-9\.\-_]+$.
1919
/// </summary>
2020
public static ValidationRule<AsyncApiComponents> KeyMustBeRegularExpression =>
2121
new ValidationRule<AsyncApiComponents>(

‎src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public static class AsyncApiDocumentRules
1616
/// </summary>
1717
private static Regex keyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, RegexTimeout);
1818

19+
private static Regex serverKeyRegex = new Regex(@"^[a-zA-Z0-9_\-]+\z", RegexOptions.None, RegexTimeout);
20+
1921
public static ValidationRule<AsyncApiDocument> DocumentRequiredFields =>
2022
new ValidationRule<AsyncApiDocument>(
2123
(context, document) =>
@@ -67,11 +69,11 @@ public static class AsyncApiDocumentRules
6769
context.Enter("servers");
6870
foreach (var key in document.Servers?.Keys)
6971
{
70-
if (!keyRegex.IsMatch(key))
72+
if (!serverKeyRegex.IsMatch(key))
7173
{
7274
context.CreateError(
7375
"ServerKeys",
74-
string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", keyRegex.ToString()));
76+
string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", serverKeyRegex.ToString()));
7577
}
7678
}
7779

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace ByteBard.AsyncAPI.Tests.Models;
2+
3+
using ByteBard.AsyncAPI.Extensions;
4+
using ByteBard.AsyncAPI.Models;
5+
using ByteBard.AsyncAPI.Readers;
6+
using FluentAssertions;
7+
using NUnit.Framework;
8+
9+
public class ChannelAddressExpressionTests
10+
{
11+
[TestCase("id")]
12+
[TestCase("id0")]
13+
[TestCase("user_id")]
14+
[TestCase("user-id")]
15+
public void NamedExpression_PreservesParametersWhenSerialized(string name)
16+
{
17+
var channel = new AsyncApiChannel { Address = "orders/{" + name + "}" };
18+
channel.Parameters.Add(name, new AsyncApiParameter { Description = "Order identifier" });
19+
20+
channel.Address.IsChannelAddressExpression().Should().BeTrue();
21+
var yaml = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
22+
23+
yaml.Should().Contain("parameters:");
24+
yaml.Should().Contain("description: Order identifier");
25+
26+
var roundTripped = new AsyncApiStringReader().ReadFragment<AsyncApiChannel>(
27+
yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostic);
28+
29+
diagnostic.Errors.Should().BeEmpty();
30+
roundTripped.Parameters.Should().ContainKey(name);
31+
roundTripped.Address.Should().Be(channel.Address);
32+
}
33+
34+
[TestCase(null)]
35+
[TestCase("")]
36+
[TestCase("orders")]
37+
[TestCase("orders/{}")]
38+
[TestCase("orders/{user.id}")]
39+
[TestCase("orders/{用户}")]
40+
[TestCase("orders/{user id}")]
41+
[TestCase("orders/{user/id}")]
42+
[TestCase("orders/{user%20id}")]
43+
[TestCase("orders/{id")]
44+
[TestCase("orders/id}")]
45+
public void AddressWithoutNamedExpression_IsNotRecognized(string? address)
46+
{
47+
address.IsChannelAddressExpression().Should().BeFalse();
48+
}
49+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
namespace ByteBard.AsyncAPI.Tests.Validation;
2+
3+
using System.Collections.Generic;
4+
using ByteBard.AsyncAPI.Readers;
5+
using FluentAssertions;
6+
using NUnit.Framework;
7+
8+
public class KeyRegexTests
9+
{
10+
[TestCase("chat.v1")]
11+
[TestCase("chat_01")]
12+
[TestCase("chat-1")]
13+
public void ChannelKey_RetainsExistingPattern(string key)
14+
{
15+
var input = $$"""
16+
asyncapi: 3.0.0
17+
info:
18+
title: Key validation
19+
version: '1.0'
20+
channels:
21+
'{{key}}': {}
22+
""";
23+
24+
var document = new AsyncApiStringReader().Read(input, out var diagnostic);
25+
26+
diagnostic.Errors.Should().BeEmpty();
27+
document.Channels.Should().ContainKey(key);
28+
}
29+
30+
public static IEnumerable<TestCaseData> ServerKeys()
31+
{
32+
foreach (var version in new[] { "2.6.0", "3.0.0" })
33+
{
34+
foreach (var key in new[] { "production", "Server_01", "server-1", "0" })
35+
{
36+
yield return new TestCaseData(version, key, true);
37+
}
38+
39+
foreach (var key in new[] { "server.one", "sérver", "server/name", "server name", "", "server\n", "server\r\n", "server\nother" })
40+
{
41+
yield return new TestCaseData(version, key, false);
42+
}
43+
}
44+
}
45+
46+
public static IEnumerable<TestCaseData> ComponentKeys()
47+
{
48+
foreach (var version in new[] { "2.6.0", "3.0.0" })
49+
{
50+
foreach (var key in new[] { "Message", "message.v1", "message_01", "message-1", "0" })
51+
{
52+
yield return new TestCaseData(version, key, true);
53+
}
54+
55+
foreach (var key in new[] { "méssage", "消息", "message\u0301", "message/name", "message name", "" })
56+
{
57+
yield return new TestCaseData(version, key, false);
58+
}
59+
}
60+
}
61+
62+
[TestCaseSource(nameof(ServerKeys))]
63+
public void ServerKey_UsesSpecificationPattern(string version, string key, bool valid)
64+
{
65+
var addressField = version == "2.6.0" ? "url" : "host";
66+
var yamlKey = key.Replace("\r", "\\r").Replace("\n", "\\n");
67+
var input = $$"""
68+
asyncapi: {{version}}
69+
info:
70+
title: Key validation
71+
version: '1.0'
72+
channels:
73+
chat: {}
74+
servers:
75+
"{{yamlKey}}":
76+
{{addressField}}: localhost
77+
protocol: kafka
78+
""";
79+
80+
var document = new AsyncApiStringReader().Read(input, out var diagnostic);
81+
82+
document.Servers.Should().ContainKey(key);
83+
if (valid)
84+
{
85+
diagnostic.Errors.Should().BeEmpty();
86+
}
87+
else
88+
{
89+
diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'servers' MUST match"));
90+
}
91+
}
92+
93+
[TestCaseSource(nameof(ComponentKeys))]
94+
public void ComponentKey_UsesAsciiSpecificationPattern(string version, string key, bool valid)
95+
{
96+
var input = $$"""
97+
asyncapi: {{version}}
98+
info:
99+
title: Key validation
100+
version: '1.0'
101+
channels:
102+
chat: {}
103+
components:
104+
messages:
105+
'{{key}}':
106+
name: example
107+
""";
108+
109+
var document = new AsyncApiStringReader().Read(input, out var diagnostic);
110+
111+
document.Components.Messages.Should().ContainKey(key);
112+
if (valid)
113+
{
114+
diagnostic.Errors.Should().BeEmpty();
115+
}
116+
else
117+
{
118+
diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'messages' MUST match"));
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)