diff --git a/README.md b/README.md index 15839a1..39b35e3 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,24 @@ The most useful validation attribute here is probably `ValidatePropertiesAttribu ## List of data annotation attributes -| Attribute | Description | -|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `GuidAttribute` | Validates that a string property is a well-formatted GUID with an optional format | -| `ContainsNonEmptyGuidAttribute` | Validates that a Guid enumerable property contains at least one non-empty Guid | -| `NotEmptyAttribute` | Validates that an enumerable property is not empty | -| `ValidatePropertiesAttribute` | Validates **all properties of a complex type property** (nested object validation) | -| `TimeSpanAttribute` | Validates that a string property is a well-formatted TimeSpan with an optional format | -| `UrlOfKindAttribute` | Validates that a string or `Uri` property is a well-formatted url of the specified `UriKind` | -| `ContainsAttribute` | Validates that a string contains the specified substring (casing can be specified) | -| `StartsWithAttribute` | Validates that a string starts with the specified substring (casing can be specified) | -| `EndsWithAttribute` | Validates that a string ends with the specified substring (casing can be specified) | -| `ProvidedByAzureKeyVaultAttribute` | Indicates that a property value might be loaded from Azure Key Vault (_has no effect_) | -| `ProvidedByAzureAppConfigAttribute` | Indicates that a property value might be loaded from Azure App Configuration (_has no effect_) | -| `SensitiveInformationAttribute` | Indicates that a property contains sensitive information, such as personally identifiable information (PII), or any other information that might result in loss of an advantage or level of security if disclosed to others (_has no effect_) | -| `NonSensitiveInformationAttribute` | Indicates that a property does not contain sensitive information (_has no effect_) | +| Attribute | Description | +|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `GuidAttribute` | Validates that a string property is a well-formatted GUID with an optional format | +| `ContainsNonEmptyGuidAttribute` | Validates that a Guid enumerable property contains at least one non-empty Guid | +| `ContainsNonEmptyStringAttribute` | Validates that a String enumerable property contains at least one non-empty String | +| `ContainsOnlyNonEmptyGuidsAttribute` | Validates that a Guid enumerable property contains only non-empty Guids | +| `ContainsOnlyNonEmptyStringsAttribute`| Validates that a String enumerable property contains only non-empty Strings | +| `NotEmptyAttribute` | Validates that an enumerable property is not empty | +| `ValidatePropertiesAttribute` | Validates **all properties of a complex type property** (nested object validation) | +| `TimeSpanAttribute` | Validates that a string property is a well-formatted TimeSpan with an optional format | +| `UrlOfKindAttribute` | Validates that a string or `Uri` property is a well-formatted url of the specified `UriKind` | +| `ContainsAttribute` | Validates that a string contains the specified substring (casing can be specified) | +| `StartsWithAttribute` | Validates that a string starts with the specified substring (casing can be specified) | +| `EndsWithAttribute` | Validates that a string ends with the specified substring (casing can be specified) | +| `ProvidedByAzureKeyVaultAttribute` | Indicates that a property value might be loaded from Azure Key Vault (_has no effect_) | +| `ProvidedByAzureAppConfigAttribute` | Indicates that a property value might be loaded from Azure App Configuration (_has no effect_) | +| `SensitiveInformationAttribute` | Indicates that a property contains sensitive information, such as personally identifiable information (PII), or any other information that might result in loss of an advantage or level of security if disclosed to others (_has no effect_) | +| `NonSensitiveInformationAttribute` | Indicates that a property does not contain sensitive information (_has no effect_) | ## License diff --git a/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyGuidsAttributeTests.cs b/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyGuidsAttributeTests.cs new file mode 100644 index 0000000..08d968e --- /dev/null +++ b/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyGuidsAttributeTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; + +namespace Workleap.ComponentModel.DataAnnotations.Tests; + +public sealed class ContainsOnlyNonEmptyGuidsAttributeTests +{ + [Theory] + [ClassData(typeof(ValidData))] + public void Given_ValidGuids_When_Validate_Then_Valid(object? inputs) + { + var attr = new ContainsOnlyNonEmptyGuidsAttribute(); + Assert.True(attr.IsValid(inputs)); + } + + [Theory] + [ClassData(typeof(InvalidData))] + public void Given_InvalidGuids_When_Validate_Then_Invalid(object? inputs) + { + var attr = new ContainsOnlyNonEmptyGuidsAttribute(); + Assert.False(attr.IsValid(inputs)); + } + + [Fact] + public void Validator_TryValidateObject_Returns_The_Expected_Error_Message_When_Validation_Fails() + { + var something = new SomeClass(); + var expectedErrorMessage = string.Format(CultureInfo.InvariantCulture, ContainsOnlyNonEmptyGuidsAttribute.ErrorMessageFormat, nameof(SomeClass.Values)); + + var results = new List(); + var context = new ValidationContext(something, serviceProvider: null, items: null); + var isValid = Validator.TryValidateObject(something, context, results, validateAllProperties: true); + + Assert.False(isValid); + var result = Assert.Single(results); + Assert.NotNull(result.ErrorMessage); + Assert.Equal(expectedErrorMessage, result.ErrorMessage); + } + + private class ValidData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object?[] { null }; + yield return new object[] { new Guid[] { } }; + yield return new object[] { new Guid?[] { } }; + yield return new object[] { new string[] { } }; + yield return new object[] { new string?[] { } }; + yield return new object[] { new Guid[] { new("f8daff85-4393-42ae-9ab5-8620ab20c8da") } }; + yield return new object[] { new string[] { "d78b48f9-37b8-47dd-8e47-0325dd3e7899" } }; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private class InvalidData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object[] { new Guid[] { default } }; + yield return new object[] { new Guid?[] { default } }; + yield return new object[] { new string[] { string.Empty } }; + yield return new object[] { new string?[] { default } }; + yield return new object[] { new Guid[] { Guid.Empty } }; + yield return new object[] { new string[] { "00000000-0000-0000-0000-000000000000" } }; + yield return new object[] { new int[] { 0 } }; + yield return new object[] { new Guid[] { default, new("846398d2-416d-4f05-86b0-431e11117abc") } }; + yield return new object[] { new Guid[] { Guid.Empty, new("08bfec4a-da43-40de-a15a-0d10f227e6b7") } }; + yield return new object[] { new Guid?[] { default, new Guid("2dd81281-7498-411c-aea1-5cec96f2dd8d") } }; + yield return new object[] { new string[] { "00000000-0000-0000-0000-000000000000", "7d8c61f7-a334-4599-b801-45f0934099a4" } }; + yield return new object[] { new string[] { string.Empty, "7c3cdfba-4466-44ab-bb95-da02eac67380" } }; + yield return new object[] { new string?[] { default, "f8daff85-4393-42ae-9ab5-8620ab20c8da" } }; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private class SomeClass + { + [ContainsOnlyNonEmptyGuids] + public Guid[] Values { get; set; } = new[] { Guid.Empty }; + } +} \ No newline at end of file diff --git a/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyStringsAttributeTests.cs b/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyStringsAttributeTests.cs new file mode 100644 index 0000000..669e4fb --- /dev/null +++ b/src/Workleap.ComponentModel.DataAnnotations.Tests/ContainsOnlyNonEmptyStringsAttributeTests.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; + +namespace Workleap.ComponentModel.DataAnnotations.Tests; + +public sealed class ContainsOnlyNonEmptyStringsAttributeTests +{ + [Theory] + [ClassData(typeof(ValidData))] + public void Given_ValidStrings_When_Validate_Then_Valid(object? inputs) + { + var attr = new ContainsOnlyNonEmptyStringsAttribute(); + Assert.True(attr.IsValid(inputs)); + } + + [Theory] + [ClassData(typeof(InvalidData))] + public void Given_InvalidGuids_When_Validate_Then_Invalid(object? inputs) + { + var attr = new ContainsOnlyNonEmptyStringsAttribute(); + var result = attr.IsValid(inputs); + Assert.False(result); + } + + [Fact] + public void Validator_TryValidateObject_Returns_The_Expected_Error_Message_When_Validation_Fails() + { + var something = new SomeClass(); + var expectedErrorMessage = string.Format(CultureInfo.InvariantCulture, ContainsOnlyNonEmptyStringsAttribute.ErrorMessageFormat, nameof(SomeClass.Values)); + + var results = new List(); + var context = new ValidationContext(something, serviceProvider: null, items: null); + var isValid = Validator.TryValidateObject(something, context, results, validateAllProperties: true); + + Assert.False(isValid); + var result = Assert.Single(results); + Assert.NotNull(result.ErrorMessage); + Assert.Equal(expectedErrorMessage, result.ErrorMessage); + } + + private class ValidData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object?[] { null }; + yield return new object[] { new string?[] { } }; + yield return new object[] { new string[] { } }; + yield return new object[] { new string[] { "Lorem ipsum dolor sit amet" } }; + yield return new object[] { new string[] { "Lorem ipsum dolor sit amet", "Etiam porta velit non nisi feugiat pulvinar" } }; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private class InvalidData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object[] { new string?[] { string.Empty, default } }; + yield return new object[] { new string?[] { string.Empty } }; + yield return new object[] { new string?[] { default } }; + yield return new object[] { new string[] { " " } }; + yield return new object[] { new string[] { string.Empty, "Lorem ipsum dolor sit amet" } }; + yield return new object[] { new string?[] { default, "Lorem ipsum dolor sit amet" } }; + yield return new object[] { new string[] { "Lorem ipsum dolor sit amet", " " } }; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private class SomeClass + { + [ContainsOnlyNonEmptyStrings] + public string?[] Values { get; set; } = new[] { string.Empty }; + } +} \ No newline at end of file diff --git a/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyGuidsAttribute.cs b/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyGuidsAttribute.cs new file mode 100644 index 0000000..53782d9 --- /dev/null +++ b/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyGuidsAttribute.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Workleap.ComponentModel.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class ContainsOnlyNonEmptyGuidsAttribute : ValidationAttribute +{ + internal const string ErrorMessageFormat = "The field {0} must be a collection that contains only non-empty GUIDs"; + + public ContainsOnlyNonEmptyGuidsAttribute() : base(ErrorMessageFormat) + { + } + + public override bool IsValid(object? value) => value switch + { + null => true, + IEnumerable enumerable => enumerable.All(this.IsValidGuid), + IEnumerable enumerable => enumerable.All(x => x != null && this.IsValidGuid(x.Value)), + IEnumerable enumerable => enumerable.All(this.IsValidGuid), + _ => false, + }; + + private bool IsValidGuid(string? valueAsString) + { + return Guid.TryParse(valueAsString, out var guid) && this.IsValidGuid(guid); + } + + private bool IsValidGuid(Guid guid) + { + return guid != Guid.Empty; + } +} \ No newline at end of file diff --git a/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyStringsAttribute.cs b/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyStringsAttribute.cs new file mode 100644 index 0000000..9335c61 --- /dev/null +++ b/src/Workleap.ComponentModel.DataAnnotations/ContainsOnlyNonEmptyStringsAttribute.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Workleap.ComponentModel.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class ContainsOnlyNonEmptyStringsAttribute : ValidationAttribute +{ + internal const string ErrorMessageFormat = "The field {0} must be a collection that contains only non-empty Strings"; + + public ContainsOnlyNonEmptyStringsAttribute() : base(ErrorMessageFormat) + { + } + + public override bool IsValid(object? value) => value switch + { + null => true, + IEnumerable enumerable => enumerable.All(x => !string.IsNullOrWhiteSpace(x)), + _ => false, + }; +} \ No newline at end of file diff --git a/src/Workleap.ComponentModel.DataAnnotations/PublicAPI.Shipped.txt b/src/Workleap.ComponentModel.DataAnnotations/PublicAPI.Shipped.txt index 93b3cde..d56f1fc 100644 --- a/src/Workleap.ComponentModel.DataAnnotations/PublicAPI.Shipped.txt +++ b/src/Workleap.ComponentModel.DataAnnotations/PublicAPI.Shipped.txt @@ -63,4 +63,10 @@ Workleap.ComponentModel.DataAnnotations.MaxValueAttribute.MaxValueAttribute(long Workleap.ComponentModel.DataAnnotations.MinValueAttribute Workleap.ComponentModel.DataAnnotations.MinValueAttribute.MinValueAttribute(double minimum) -> void Workleap.ComponentModel.DataAnnotations.MinValueAttribute.MinValueAttribute(int minimum) -> void -Workleap.ComponentModel.DataAnnotations.MinValueAttribute.MinValueAttribute(long minimum) -> void \ No newline at end of file +Workleap.ComponentModel.DataAnnotations.MinValueAttribute.MinValueAttribute(long minimum) -> void +Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyGuidsAttribute +Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyGuidsAttribute.ContainsOnlyNonEmptyGuidsAttribute() -> void +override Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyGuidsAttribute.IsValid(object? value) -> bool +Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyStringsAttribute +Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyStringsAttribute.ContainsOnlyNonEmptyStringsAttribute() -> void +override Workleap.ComponentModel.DataAnnotations.ContainsOnlyNonEmptyStringsAttribute.IsValid(object? value) -> bool \ No newline at end of file