diff --git a/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs b/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs index 622a7b7..bd2d4da 100644 --- a/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs +++ b/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs @@ -4,7 +4,7 @@ public static class AsyncApiExtensions { - private static Regex channelAddressExpressionRegex = new Regex("{[a-zA-Z1-9_-]*}"); + private static Regex channelAddressExpressionRegex = new Regex(@"\{[A-Za-z0-9_-]+\}"); public static bool IsChannelAddressExpression(this string address) { diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs index 380ef42..e22765f 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs @@ -11,11 +11,11 @@ public static class AsyncApiComponentsRules /// /// The key regex. /// - public static Regex KeyRegex = new Regex(@"^[\w\d\.\-_]+$"); + public static Regex KeyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$"); /// /// All the fixed fields declared above are objects - /// that MUST use keys that match the regular expression: ^[\w\d\.\-_]+$. + /// that MUST use keys that match the regular expression: ^[a-zA-Z0-9\.\-_]+$. /// public static ValidationRule KeyMustBeRegularExpression => new ValidationRule( diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs index b96f94d..b5c911d 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs @@ -16,6 +16,8 @@ public static class AsyncApiDocumentRules /// private static Regex keyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, RegexTimeout); + private static Regex serverKeyRegex = new Regex(@"^[a-zA-Z0-9_\-]+\z", RegexOptions.None, RegexTimeout); + public static ValidationRule DocumentRequiredFields => new ValidationRule( (context, document) => @@ -67,11 +69,11 @@ public static class AsyncApiDocumentRules context.Enter("servers"); foreach (var key in document.Servers?.Keys) { - if (!keyRegex.IsMatch(key)) + if (!serverKeyRegex.IsMatch(key)) { context.CreateError( "ServerKeys", - string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", keyRegex.ToString())); + string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", serverKeyRegex.ToString())); } } diff --git a/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs b/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs new file mode 100644 index 0000000..af5be0b --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs @@ -0,0 +1,49 @@ +namespace ByteBard.AsyncAPI.Tests.Models; + +using ByteBard.AsyncAPI.Extensions; +using ByteBard.AsyncAPI.Models; +using ByteBard.AsyncAPI.Readers; +using FluentAssertions; +using NUnit.Framework; + +public class ChannelAddressExpressionTests +{ + [TestCase("id")] + [TestCase("id0")] + [TestCase("user_id")] + [TestCase("user-id")] + public void NamedExpression_PreservesParametersWhenSerialized(string name) + { + var channel = new AsyncApiChannel { Address = "orders/{" + name + "}" }; + channel.Parameters.Add(name, new AsyncApiParameter { Description = "Order identifier" }); + + channel.Address.IsChannelAddressExpression().Should().BeTrue(); + var yaml = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + yaml.Should().Contain("parameters:"); + yaml.Should().Contain("description: Order identifier"); + + var roundTripped = new AsyncApiStringReader().ReadFragment( + yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + roundTripped.Parameters.Should().ContainKey(name); + roundTripped.Address.Should().Be(channel.Address); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("orders")] + [TestCase("orders/{}")] + [TestCase("orders/{user.id}")] + [TestCase("orders/{用户}")] + [TestCase("orders/{user id}")] + [TestCase("orders/{user/id}")] + [TestCase("orders/{user%20id}")] + [TestCase("orders/{id")] + [TestCase("orders/id}")] + public void AddressWithoutNamedExpression_IsNotRecognized(string? address) + { + address.IsChannelAddressExpression().Should().BeFalse(); + } +} diff --git a/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs new file mode 100644 index 0000000..a8bc733 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs @@ -0,0 +1,121 @@ +namespace ByteBard.AsyncAPI.Tests.Validation; + +using System.Collections.Generic; +using ByteBard.AsyncAPI.Readers; +using FluentAssertions; +using NUnit.Framework; + +public class KeyRegexTests +{ + [TestCase("chat.v1")] + [TestCase("chat_01")] + [TestCase("chat-1")] + public void ChannelKey_RetainsExistingPattern(string key) + { + var input = $$""" + asyncapi: 3.0.0 + info: + title: Key validation + version: '1.0' + channels: + '{{key}}': {} + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + document.Channels.Should().ContainKey(key); + } + + public static IEnumerable ServerKeys() + { + foreach (var version in new[] { "2.6.0", "3.0.0" }) + { + foreach (var key in new[] { "production", "Server_01", "server-1", "0" }) + { + yield return new TestCaseData(version, key, true); + } + + foreach (var key in new[] { "server.one", "sérver", "server/name", "server name", "", "server\n", "server\r\n", "server\nother" }) + { + yield return new TestCaseData(version, key, false); + } + } + } + + public static IEnumerable ComponentKeys() + { + foreach (var version in new[] { "2.6.0", "3.0.0" }) + { + foreach (var key in new[] { "Message", "message.v1", "message_01", "message-1", "0" }) + { + yield return new TestCaseData(version, key, true); + } + + foreach (var key in new[] { "méssage", "消息", "message\u0301", "message/name", "message name", "" }) + { + yield return new TestCaseData(version, key, false); + } + } + } + + [TestCaseSource(nameof(ServerKeys))] + public void ServerKey_UsesSpecificationPattern(string version, string key, bool valid) + { + var addressField = version == "2.6.0" ? "url" : "host"; + var yamlKey = key.Replace("\r", "\\r").Replace("\n", "\\n"); + var input = $$""" + asyncapi: {{version}} + info: + title: Key validation + version: '1.0' + channels: + chat: {} + servers: + "{{yamlKey}}": + {{addressField}}: localhost + protocol: kafka + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + document.Servers.Should().ContainKey(key); + if (valid) + { + diagnostic.Errors.Should().BeEmpty(); + } + else + { + diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'servers' MUST match")); + } + } + + [TestCaseSource(nameof(ComponentKeys))] + public void ComponentKey_UsesAsciiSpecificationPattern(string version, string key, bool valid) + { + var input = $$""" + asyncapi: {{version}} + info: + title: Key validation + version: '1.0' + channels: + chat: {} + components: + messages: + '{{key}}': + name: example + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + document.Components.Messages.Should().ContainKey(key); + if (valid) + { + diagnostic.Errors.Should().BeEmpty(); + } + else + { + diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'messages' MUST match")); + } + } +}