diff --git a/src/LEGO.AsyncAPI/Resource.Designer.cs b/src/LEGO.AsyncAPI/Resource.Designer.cs index 755eda9c..b28de91a 100644 --- a/src/LEGO.AsyncAPI/Resource.Designer.cs +++ b/src/LEGO.AsyncAPI/Resource.Designer.cs @@ -60,6 +60,15 @@ internal Resource() { } } + /// + /// Looks up a localized string similar to Channel signature '{0}' MUST be unique.. + /// + internal static string Validation_ChannelsMustBeUnique { + get { + return ResourceManager.GetString("Validation_ChannelsMustBeUnique", resourceCulture); + } + } + /// /// Looks up a localized string similar to The string '{0}' MUST be an email address.. /// diff --git a/src/LEGO.AsyncAPI/Resource.resx b/src/LEGO.AsyncAPI/Resource.resx index 9d882464..a97d5939 100644 --- a/src/LEGO.AsyncAPI/Resource.resx +++ b/src/LEGO.AsyncAPI/Resource.resx @@ -117,6 +117,9 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Channel signature '{0}' MUST be unique. + The string '{0}' MUST be an email address. diff --git a/src/LEGO.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs b/src/LEGO.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs index 27076369..d866c4df 100644 --- a/src/LEGO.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs +++ b/src/LEGO.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs @@ -2,6 +2,8 @@ namespace LEGO.AsyncAPI.Validation.Rules { + using System; + using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using LEGO.AsyncAPI.Models; @@ -10,10 +12,13 @@ namespace LEGO.AsyncAPI.Validation.Rules [AsyncApiRule] public static class AsyncApiDocumentRules { + private static TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + /// /// The key regex. /// - public static Regex KeyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$"); + public static Regex KeyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, RegexTimeout); + public static Regex ChannelKeyUriTemplateRegex = new Regex(@"^(?:(?:[^\x00-\x20""'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$", RegexOptions.IgnoreCase, RegexTimeout); public static ValidationRule DocumentRequiredFields => new ValidationRule( @@ -30,16 +35,58 @@ public static class AsyncApiDocumentRules context.Exit(); context.Enter("channels"); - if (document.Channels == null || !document.Channels.Keys.Any()) + try { - context.CreateError( - nameof(DocumentRequiredFields), - string.Format(Resource.Validation_FieldRequired, "channels", "document")); - } + // MUST have at least 1 channel + if (document.Channels == null || !document.Channels.Keys.Any()) + { + context.CreateError( + nameof(DocumentRequiredFields), + string.Format(Resource.Validation_FieldRequired, "channels", "document")); + return; + } + var hashSet = new HashSet(); + foreach (var key in document.Channels.Keys) + { + // Uri-template + if (!ChannelKeyUriTemplateRegex.IsMatch(key)) + { + context.CreateError( + "ChannelKeys", + string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "channels", KeyRegex.ToString())); + } - context.Exit(); + // Unique channel keys + var pathSignature = GetKeySignature(key); + if (!hashSet.Add(pathSignature)) + { + context.CreateError("ChannelKey", string.Format(Resource.Validation_ChannelsMustBeUnique, pathSignature)); + } + } + } + finally + { + context.Exit(); + } }); + private static string GetKeySignature(string path) + { + for (int openBrace = path.IndexOf('{'); openBrace > -1; openBrace = path.IndexOf('{', openBrace + 2)) + { + int closeBrace = path.IndexOf('}', openBrace); + + if (closeBrace < 0) + { + return path; + } + + path = path.Substring(0, openBrace + 1) + path.Substring(closeBrace); + } + + return path; + } + public static ValidationRule KeyMustBeRegularExpression => new ValidationRule( (context, document) => diff --git a/test/LEGO.AsyncAPI.Tests/Validation/ValidationRuleTests.cs b/test/LEGO.AsyncAPI.Tests/Validation/ValidationRuleTests.cs new file mode 100644 index 00000000..5d9f7bec --- /dev/null +++ b/test/LEGO.AsyncAPI.Tests/Validation/ValidationRuleTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Tests.Validation +{ + using FluentAssertions; + using LEGO.AsyncAPI.Readers; + using LEGO.AsyncAPI.Validations; + using NUnit.Framework; + using System.Linq; + + public class ValidationRuleTests + { + [Test] + [TestCase("chat-{person-id}")] + public void ChannelKey_WithInvalidParameter_DiagnosticsError(string channelKey) + { + var input = + $""" + asyncapi: 2.6.0 + info: + title: Chat Application + version: 1.0.0 + servers: + testing: + url: test.mosquitto.org:1883 + protocol: mqtt + description: Test broker + channels: + {channelKey}: + publish: + operationId: onMessageReceieved + message: + name: text + payload: + type: string + subscribe: + operationId: sendMessage + message: + name: text + payload: + type: string + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + diagnostic.Errors.First().Message.Should().Be($"The key '{channelKey}' in 'channels' MUST match the regular expression '^[a-zA-Z0-9\\.\\-_]+$'."); + diagnostic.Errors.First().Pointer.Should().Be("#/channels"); + } + + [Test] + public void ChannelKey_WithNonUniqueKey_DiagnosticsError() + { + var input = + """ + asyncapi: 2.6.0 + info: + title: Chat Application + version: 1.0.0 + servers: + testing: + url: test.mosquitto.org:1883 + protocol: mqtt + description: Test broker + channels: + chat/{personId}: + publish: + operationId: onMessageReceieved + message: + name: text + payload: + type: string + chat/{personIdentity}: + publish: + operationId: onMessageReceieved + message: + name: text + payload: + type: string + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + diagnostic.Errors.First().Message.Should().Be("Channel signature 'chat/{}' MUST be unique."); + diagnostic.Errors.First().Pointer.Should().Be("#/channels"); + } + + [Test] + [TestCase("chat")] + [TestCase("/some/chat/{personId}")] + [TestCase("chat-{personId}")] + [TestCase("chat-{person_id}")] + [TestCase("chat-{person%2Did}")] + [TestCase("chat-{personId2}")] + public void ChannelKey_WithValidKey_Success(string channelKey) + { + var input = + $""" + asyncapi: 2.6.0 + info: + title: Chat Application + version: 1.0.0 + servers: + testing: + url: test.mosquitto.org:1883 + protocol: mqtt + description: Test broker + channels: + {channelKey}: + publish: + operationId: onMessageReceieved + message: + name: text + payload: + type: string + subscribe: + operationId: sendMessage + message: + name: text + payload: + type: string + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + diagnostic.Errors.Should().BeEmpty(); + } + } + +}