feat: add request validation
This commit is contained in:
parent
1218d3617a
commit
de2d0e2693
32 changed files with 1829 additions and 1 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="src/request.validation/Geekeey.Request.Validation.csproj" />
|
||||||
|
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
|
||||||
<Project Path="src/request/Geekeey.Request.csproj" />
|
<Project Path="src/request/Geekeey.Request.csproj" />
|
||||||
<Project Path="src/request.tests/Geekeey.Request.Tests.csproj" />
|
<Project Path="src/request.tests/Geekeey.Request.Tests.csproj" />
|
||||||
<Project Path="src/request.result/Geekeey.Request.Result.csproj" />
|
<Project Path="src/request.result/Geekeey.Request.Result.csproj" />
|
||||||
<Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" />
|
<Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
15
src/request.validation.tests/.editorconfig
Normal file
15
src/request.validation.tests/.editorconfig
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
[*.{cs,vb}]
|
||||||
|
# disable CA1822: Mark members as static
|
||||||
|
# -> TUnit requiring instance methods for test cases
|
||||||
|
dotnet_diagnostic.CA1822.severity = none
|
||||||
|
# disable CA1707: Identifiers should not contain underscores
|
||||||
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
# disable IDE0060: Remove unused parameter
|
||||||
|
dotnet_diagnostic.IDE0060.severity = none
|
||||||
|
# disable IDE0005: Unnecessary using directive
|
||||||
|
dotnet_diagnostic.IDE0005.severity = none
|
||||||
|
# disable IDE0390: Method can be made synchronous
|
||||||
|
dotnet_diagnostic.IDE0390.severity = none
|
||||||
|
# disable IDE0391: Method can be made synchronous
|
||||||
|
dotnet_diagnostic.IDE0391.severity = none
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="TUnit" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\request.validation\Geekeey.Request.Validation.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
326
src/request.validation.tests/RuleBuilderExtensionsTests.cs
Normal file
326
src/request.validation.tests/RuleBuilderExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class RuleBuilderExtensionsTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_null_for_reference_types()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<ReferenceValueModel, object?>(model =>
|
||||||
|
model.Value, rule => rule.NotNull());
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new ReferenceValueModel());
|
||||||
|
var valid = validator.Validate(new ReferenceValueModel { Value = new object() });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(ReferenceValueModel.Value), "Value is required.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_null_for_nullable_value_types()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<NullableIntValueModel, int?>(model =>
|
||||||
|
model.Value, rule => rule.NotNull());
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new NullableIntValueModel());
|
||||||
|
var valid = validator.Validate(new NullableIntValueModel { Value = 1 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(NullableIntValueModel.Value), "Value is required.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_empty_for_strings()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model =>
|
||||||
|
model.Value, rule => rule.NotEmpty());
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = " " });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "abc" });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is required.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_empty_for_collections()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<CollectionValueModel, IEnumerable<string>?>(model =>
|
||||||
|
model.Value, rule => rule.NotEmpty());
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new CollectionValueModel { Value = [] });
|
||||||
|
var valid = validator.Validate(new CollectionValueModel { Value = ["item"] });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(CollectionValueModel.Value), "Value is required.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_min_length()
|
||||||
|
{
|
||||||
|
var validator =
|
||||||
|
new PropertyValidator<StringValueModel, string?>(model =>
|
||||||
|
model.Value, rule => rule.MinLength(3));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "ab" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "abc" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at least 3 characters long.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_max_length()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.MaxLength(5));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "abcdef" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "abcde" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at most 5 characters long.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_length()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Length(2, 4));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "a" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ab" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value),
|
||||||
|
"Value must be between 2 and 4 characters long.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_greater_than()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.GreaterThan(18));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new IntValueModel { Value = 18 });
|
||||||
|
var valid = validator.Validate(new IntValueModel { Value = 19 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than 18.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_greater_than_or_equal_to()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.GreaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new IntValueModel { Value = 0 });
|
||||||
|
var valid = validator.Validate(new IntValueModel { Value = 1 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than or equal to 1.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_less_than()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.LessThan(10));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new IntValueModel { Value = 10 });
|
||||||
|
var valid = validator.Validate(new IntValueModel { Value = 9 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than 10.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_less_than_or_equal_to()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.LessThanOrEqualTo(5));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new IntValueModel { Value = 6 });
|
||||||
|
var valid = validator.Validate(new IntValueModel { Value = 5 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than or equal to 5.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_between()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.Between(0, 100));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new IntValueModel { Value = 101 });
|
||||||
|
var valid = validator.Validate(new IntValueModel { Value = 100 });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be between 0 and 100.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_equal()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Equal("ACTIVE"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_equal()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.NotEqual("BANNED"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_equal_with_null_reference_values()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Equal("ACTIVE"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
|
||||||
|
var nullValue = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(nullValue.IsValid).IsFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_not_equal_with_null_reference_values()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.NotEqual("BANNED"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
|
||||||
|
var nullValue = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(nullValue.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_skip_null_values_for_reference_greater_than()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.GreaterThan("B"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "A" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "C" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be greater than B.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_skip_null_values_for_reference_between()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Between("A", "C"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "D" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "C" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be between A and C.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_matches_with_a_pattern()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Matches("^[A-Z]+$"));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "Abc" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "ABC" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_matches_with_a_regex()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Matches(new Regex(@"^\d{4}$", RegexOptions.CultureInvariant)));
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new StringValueModel { Value = "12AB" });
|
||||||
|
var valid = validator.Validate(new StringValueModel { Value = "1234" });
|
||||||
|
var ignoredNull = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(ignoredNull.IsValid).IsTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_for_invalid_length_configuration()
|
||||||
|
{
|
||||||
|
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.Length(5, 4)))
|
||||||
|
.Throws<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_for_negative_min_length_configuration()
|
||||||
|
{
|
||||||
|
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
|
||||||
|
=> model.Value, rule => rule.MinLength(-1)))
|
||||||
|
.Throws<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_for_invalid_between_configuration()
|
||||||
|
{
|
||||||
|
await Assert.That(() => new PropertyValidator<IntValueModel, int>(model
|
||||||
|
=> model.Value, rule => rule.Between(10, 0)))
|
||||||
|
.Throws<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertSingleProblem(Validation validation, string propertyName, string message)
|
||||||
|
{
|
||||||
|
await Assert.That(validation.Problems).Count().IsEqualTo(1);
|
||||||
|
|
||||||
|
var problem = validation.Problems.Single();
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(problem.PropertyName).IsEqualTo(propertyName);
|
||||||
|
await Assert.That(problem.Message).IsEqualTo(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/request.validation.tests/ValidatorTests.cs
Normal file
279
src/request.validation.tests/ValidatorTests.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class ValidatorTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_a_property_rule_with_metadata()
|
||||||
|
{
|
||||||
|
var validator = CreatePersonValidator();
|
||||||
|
|
||||||
|
var result = validator.Validate(new Person { Name = "" });
|
||||||
|
|
||||||
|
await Assert.That(result.IsValid).IsFalse();
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
|
||||||
|
var problem = result.Problems.Single();
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name));
|
||||||
|
await Assert.That(problem.Message).IsEqualTo("Name is required.");
|
||||||
|
await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED");
|
||||||
|
await Assert.That(problem.Severity).IsEqualTo(Severity.Warning);
|
||||||
|
await Assert.That(problem.AttemptedValue).IsEqualTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_validate_all_rules_on_every_call()
|
||||||
|
{
|
||||||
|
var validator = CreatePersonValidator();
|
||||||
|
|
||||||
|
var invalid = validator.Validate(new Person { Name = "" });
|
||||||
|
var valid = validator.Validate(new Person { Name = "Louis" });
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(invalid.IsValid).IsFalse();
|
||||||
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
|
await Assert.That(valid.Problems).IsEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<PersonWithAddress, Address?>(
|
||||||
|
person => person.Address,
|
||||||
|
rule => rule.SetValidator(CreateAddressValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new PersonWithAddress
|
||||||
|
{
|
||||||
|
Address = new Address { Street = "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_skip_null_nested_values_for_composed_validators()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<PersonWithAddress, Address?>(
|
||||||
|
person => person.Address,
|
||||||
|
rule => rule.SetValidator(CreateAddressValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new PersonWithAddress());
|
||||||
|
|
||||||
|
await Assert.That(result.IsValid).IsTrue();
|
||||||
|
await Assert.That(result.Problems).IsEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_validate_each_collection_element_and_include_the_index()
|
||||||
|
{
|
||||||
|
var validator = new CollectionValidator<Team, Member>(
|
||||||
|
team => team.Members,
|
||||||
|
rule => rule.SetValidator(CreateMemberValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new Team
|
||||||
|
{
|
||||||
|
Members = [new Member { Name = "Ada" }, new Member { Name = "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Members[1].Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_build_property_paths_for_nested_member_access()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<PersonWithAddress, string?>(
|
||||||
|
person => person.Address!.Street,
|
||||||
|
rule => rule.NotEmpty());
|
||||||
|
|
||||||
|
var result = validator.Validate(new PersonWithAddress { Address = new Address { Street = "" } });
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
|
||||||
|
var problem = result.Problems.Single();
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(problem.PropertyName).IsEqualTo("Address.Street");
|
||||||
|
await Assert.That(problem.Message).IsEqualTo("Value is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_build_property_paths_for_converted_expressions()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<Person, object?>(
|
||||||
|
person => person.Name,
|
||||||
|
rule => rule.NotNull());
|
||||||
|
|
||||||
|
var result = validator.Validate(new Person());
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
|
||||||
|
var problem = result.Problems.Single();
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name));
|
||||||
|
await Assert.That(problem.Message).IsEqualTo("Value is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_for_non_member_expressions()
|
||||||
|
{
|
||||||
|
await Assert.That(() => new PropertyValidator<Person, int>(
|
||||||
|
person => person.Name!.Trim().Length,
|
||||||
|
rule => rule.GreaterThan(0)))
|
||||||
|
.Throws<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_aggregate_all_failures_in_rule_order()
|
||||||
|
{
|
||||||
|
var validator = new OrderedFailuresValidator();
|
||||||
|
|
||||||
|
var result = validator.Validate(new StringValueModel());
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(3);
|
||||||
|
await Assert.That(result.Problems[0].Message).IsEqualTo("First failure.");
|
||||||
|
await Assert.That(result.Problems[1].Message).IsEqualTo("Second failure.");
|
||||||
|
await Assert.That(result.Problems[2].Message).IsEqualTo("Third failure.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_apply_default_problem_metadata()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(
|
||||||
|
model => model.Value,
|
||||||
|
rule => rule.NotEmpty());
|
||||||
|
|
||||||
|
var result = validator.Validate(new StringValueModel { Value = " " });
|
||||||
|
var problem = result.Problems.Single();
|
||||||
|
|
||||||
|
using (Assert.Multiple())
|
||||||
|
{
|
||||||
|
await Assert.That(problem.Code).IsNull();
|
||||||
|
await Assert.That(problem.Severity).IsEqualTo(Severity.Error);
|
||||||
|
await Assert.That(problem.AttemptedValue).IsEqualTo(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_skip_null_collections_for_collection_validators()
|
||||||
|
{
|
||||||
|
var validator = new CollectionValidator<NullableTeam, Member>(
|
||||||
|
team => team.Members!,
|
||||||
|
rule => rule.SetValidator(CreateMemberValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new NullableTeam());
|
||||||
|
|
||||||
|
await Assert.That(result.IsValid).IsTrue();
|
||||||
|
await Assert.That(result.Problems).IsEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_skip_empty_collections_for_collection_validators()
|
||||||
|
{
|
||||||
|
var validator = new CollectionValidator<Team, Member>(
|
||||||
|
team => team.Members,
|
||||||
|
rule => rule.SetValidator(CreateMemberValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new Team());
|
||||||
|
|
||||||
|
await Assert.That(result.IsValid).IsTrue();
|
||||||
|
await Assert.That(result.Problems).IsEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_aggregate_problems_for_multiple_invalid_collection_elements()
|
||||||
|
{
|
||||||
|
var validator = new CollectionValidator<Team, Member>(
|
||||||
|
team => team.Members,
|
||||||
|
rule => rule.SetValidator(CreateMemberValidator()));
|
||||||
|
|
||||||
|
var result = validator.Validate(new Team
|
||||||
|
{
|
||||||
|
Members = [new Member { Name = "" }, new Member { Name = "Ada" }, new Member { Name = "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(2);
|
||||||
|
await Assert.That(result.Problems[0].PropertyName).IsEqualTo("Members[0].Name");
|
||||||
|
await Assert.That(result.Problems[1].PropertyName).IsEqualTo("Members[2].Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_resolve_nested_validators_from_the_context_service_provider()
|
||||||
|
{
|
||||||
|
var addressValidator = CreateAddressValidator();
|
||||||
|
var services = new ServiceCollection()
|
||||||
|
.AddSingleton(addressValidator)
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
var validator = new PropertyValidator<PersonWithAddress, Address?>(
|
||||||
|
person => person.Address,
|
||||||
|
rule => rule.SetValidator<PropertyValidator<Address, string?>>());
|
||||||
|
var context = new ValidationContext<PersonWithAddress>(
|
||||||
|
new PersonWithAddress { Address = new Address { Street = "" } },
|
||||||
|
services);
|
||||||
|
|
||||||
|
var result = validator.Validate(context);
|
||||||
|
|
||||||
|
await Assert.That(result.Problems).Count().IsEqualTo(1);
|
||||||
|
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_for_type_mismatch()
|
||||||
|
{
|
||||||
|
var validator = CreatePersonValidator();
|
||||||
|
var context = new ValidationContext<object>(new object());
|
||||||
|
|
||||||
|
await Assert.That(() => validator.Validate(context)).Throws<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_see_it_throw_when_a_context_resolved_validator_cannot_be_resolved()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<PersonWithAddress, Address?>(
|
||||||
|
person => person.Address,
|
||||||
|
rule => rule.SetValidator<PropertyValidator<Address, string?>>());
|
||||||
|
var context = new ValidationContext<PersonWithAddress>(
|
||||||
|
new PersonWithAddress { Address = new Address { Street = "" } });
|
||||||
|
|
||||||
|
await Assert.That(() => validator.Validate(context)).Throws<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PropertyValidator<Person, string?> CreatePersonValidator()
|
||||||
|
{
|
||||||
|
return new PropertyValidator<Person, string?>(
|
||||||
|
person => person.Name,
|
||||||
|
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Name is required.")
|
||||||
|
.WithCode("NAME_REQUIRED")
|
||||||
|
.WithSeverity(Severity.Warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PropertyValidator<Address, string?> CreateAddressValidator()
|
||||||
|
{
|
||||||
|
return new PropertyValidator<Address, string?>(
|
||||||
|
address => address.Street,
|
||||||
|
rule => rule.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PropertyValidator<Member, string?> CreateMemberValidator()
|
||||||
|
{
|
||||||
|
return new PropertyValidator<Member, string?>(
|
||||||
|
member => member.Name,
|
||||||
|
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required."));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/Address.cs
Normal file
9
src/request.validation.tests/_fixtures/Address.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class Address
|
||||||
|
{
|
||||||
|
public string? Street { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class CollectionValidator<T, TElement> : Validator<T>
|
||||||
|
{
|
||||||
|
public CollectionValidator(
|
||||||
|
Expression<Func<T, IEnumerable<TElement>>> expression,
|
||||||
|
Action<IPropertyRuleBuilder<T, TElement>> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expression);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
configure(RuleForEach(expression));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class CollectionValueModel
|
||||||
|
{
|
||||||
|
public IEnumerable<string>? Value { get; init; }
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/IntValueModel.cs
Normal file
9
src/request.validation.tests/_fixtures/IntValueModel.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class IntValueModel
|
||||||
|
{
|
||||||
|
public int Value { get; init; }
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/Member.cs
Normal file
9
src/request.validation.tests/_fixtures/Member.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class Member
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class NullableIntValueModel
|
||||||
|
{
|
||||||
|
public int? Value { get; init; }
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/NullableTeam.cs
Normal file
9
src/request.validation.tests/_fixtures/NullableTeam.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class NullableTeam
|
||||||
|
{
|
||||||
|
public IEnumerable<Member>? Members { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class OrderedFailuresValidator : Validator<StringValueModel>
|
||||||
|
{
|
||||||
|
public OrderedFailuresValidator()
|
||||||
|
{
|
||||||
|
RuleFor(model => model.Value)
|
||||||
|
.Must(_ => false, "First failure.")
|
||||||
|
.Must(_ => false, "Second failure.");
|
||||||
|
|
||||||
|
RuleFor(model => model.Value)
|
||||||
|
.Must(_ => false, "Third failure.");
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/Person.cs
Normal file
9
src/request.validation.tests/_fixtures/Person.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class Person
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class PersonWithAddress
|
||||||
|
{
|
||||||
|
public Address? Address { get; init; }
|
||||||
|
}
|
||||||
19
src/request.validation.tests/_fixtures/PropertyValidator.cs
Normal file
19
src/request.validation.tests/_fixtures/PropertyValidator.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class PropertyValidator<T, TProperty> : Validator<T>
|
||||||
|
{
|
||||||
|
public PropertyValidator(
|
||||||
|
Expression<Func<T, TProperty>> expression,
|
||||||
|
Action<IPropertyRuleBuilder<T, TProperty>> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expression);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
configure(RuleFor(expression));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class ReferenceValueModel
|
||||||
|
{
|
||||||
|
public object? Value { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class StringValueModel
|
||||||
|
{
|
||||||
|
public string? Value { get; init; }
|
||||||
|
}
|
||||||
9
src/request.validation.tests/_fixtures/Team.cs
Normal file
9
src/request.validation.tests/_fixtures/Team.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright (c) The Geekeey Authors
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
namespace Geekeey.Request.Validation.Tests;
|
||||||
|
|
||||||
|
internal sealed class Team
|
||||||
|
{
|
||||||
|
public IEnumerable<Member> Members { get; init; } = [];
|
||||||
|
}
|
||||||
30
src/request.validation/Geekeey.Request.Validation.csproj
Normal file
30
src/request.validation/Geekeey.Request.Validation.csproj
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||||
|
<InternalsVisibleTo Include="Geekeey.Request.Validation.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
|
||||||
|
<PackageIcon>package-icon.png</PackageIcon>
|
||||||
|
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request.validation</PackageProjectUrl>
|
||||||
|
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
|
||||||
|
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
|
||||||
|
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue