feat: add request validation
This commit is contained in:
parent
1218d3617a
commit
de2d0e2693
32 changed files with 1829 additions and 1 deletions
|
|
@ -1,4 +1,6 @@
|
|||
<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.tests/Geekeey.Request.Tests.csproj" />
|
||||
<Project Path="src/request.result/Geekeey.Request.Result.csproj" />
|
||||
|
|
|
|||
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>
|
||||
48
src/request.validation/IPropertyRuleBuilder.cs
Normal file
48
src/request.validation/IPropertyRuleBuilder.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a builder for defining a validation rule for a specific property of a type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object to be validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
|
||||
public interface IPropertyRuleBuilder<T, out TProperty>
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a validation rule that must be met for the property to be considered valid.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The predicate function that determines if the property value is valid.</param>
|
||||
/// <param name="message">The error message to be returned if the validation fails.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the validator to be used for validating the property value.
|
||||
/// </summary>
|
||||
/// <param name="validator">The validator instance to use for validation.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the validator to be used for validating the property value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValidator">The type of the validator to use for validation.</typeparam>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error code for the validation rule.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code to be associated with the validation rule.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the severity of the validation rule.
|
||||
/// </summary>
|
||||
/// <param name="severity">The severity level of the validation rule.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity);
|
||||
}
|
||||
31
src/request.validation/IValidator.cs
Normal file
31
src/request.validation/IValidator.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validator for a particular type.
|
||||
/// </summary>
|
||||
public interface IValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs validation on the provided <see cref="ValidationContext"/> and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="context">The validation context containing the instance to validate, service provider, and additional items.</param>
|
||||
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
|
||||
Validation Validate(ValidationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validator for a particular type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to validate.</typeparam>
|
||||
public interface IValidator<in T> : IValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the validation logic for a specified instance of type <typeparamref name="T"/> and returns the validation result.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance of type <typeparamref name="T"/> to validate.</param>
|
||||
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
|
||||
Validation Validate(T instance);
|
||||
}
|
||||
35
src/request.validation/Problem.cs
Normal file
35
src/request.validation/Problem.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation problem identified during a request validation process.
|
||||
/// </summary>
|
||||
public record Problem
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the property.
|
||||
/// </summary>
|
||||
public required string PropertyName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom severity level associated with the failure.
|
||||
/// </summary>
|
||||
public Severity Severity { get; init; } = Severity.Error;
|
||||
|
||||
/// <summary>
|
||||
/// The error message
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error code.
|
||||
/// </summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The property value that caused the failure.
|
||||
/// </summary>
|
||||
public object? AttemptedValue { get; init; }
|
||||
}
|
||||
139
src/request.validation/Rule.cs
Normal file
139
src/request.validation/Rule.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
internal abstract record Rule
|
||||
{
|
||||
public Severity Severity { get; init; } = Severity.Error;
|
||||
|
||||
public string? Code { get; init; }
|
||||
|
||||
public abstract IEnumerable<Problem> Validate(ValidationContext context);
|
||||
}
|
||||
|
||||
internal abstract record Rule<T, TProperty> : Rule
|
||||
{
|
||||
protected Rule(Expression expression)
|
||||
{
|
||||
PropertyName = GetPropertyPath(expression);
|
||||
}
|
||||
|
||||
public string PropertyName { get; }
|
||||
|
||||
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
|
||||
|
||||
private static string GetPropertyPath(Expression expression)
|
||||
{
|
||||
var members = new Stack<string>();
|
||||
|
||||
var current = expression;
|
||||
while (current is not null)
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case LambdaExpression lambdaExpression:
|
||||
current = lambdaExpression.Body;
|
||||
break;
|
||||
case MemberExpression memberExpression:
|
||||
members.Push(memberExpression.Member.Name);
|
||||
current = memberExpression.Expression;
|
||||
break;
|
||||
case UnaryExpression
|
||||
{
|
||||
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked
|
||||
} unaryExpression:
|
||||
current = unaryExpression.Operand;
|
||||
break;
|
||||
case ParameterExpression:
|
||||
current = null;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Only simple member access expressions are supported.");
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(".", members);
|
||||
}
|
||||
|
||||
public override IEnumerable<Problem> Validate(ValidationContext context)
|
||||
{
|
||||
if (context.Instance is T instance)
|
||||
{
|
||||
return Validate(instance, context);
|
||||
}
|
||||
|
||||
if (context.Instance is null && default(T) is null)
|
||||
{
|
||||
return Validate((T)context.Instance!, context);
|
||||
}
|
||||
|
||||
var actualType = context.Instance?.GetType().FullName ?? "null";
|
||||
throw new InvalidOperationException(
|
||||
$"Expected validation context instance of type '{typeof(T).FullName}', but got '{actualType}'.");
|
||||
}
|
||||
|
||||
protected abstract IEnumerable<Problem> Validate(T instance, ValidationContext context);
|
||||
}
|
||||
|
||||
internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
|
||||
{
|
||||
private readonly Func<T, TProperty> _accessor;
|
||||
|
||||
public PropertyRule(Expression<Func<T, TProperty>> expression) : base(expression)
|
||||
{
|
||||
_accessor = expression.Compile();
|
||||
}
|
||||
|
||||
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
|
||||
{
|
||||
if (Steps.Count is 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var value = _accessor(instance);
|
||||
return Steps.SelectMany(step => step.Validate(value, context, PropertyName, Code, Severity));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CollectionRule<T, TElement> : Rule<T, TElement>
|
||||
{
|
||||
private readonly Func<T, IEnumerable<TElement>> _accessor;
|
||||
|
||||
public CollectionRule(Expression<Func<T, IEnumerable<TElement>>> expression) : base(expression)
|
||||
{
|
||||
_accessor = expression.Compile();
|
||||
}
|
||||
|
||||
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
|
||||
{
|
||||
if (Steps.Count is 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (_accessor(instance) is not { } collection)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var element in collection)
|
||||
{
|
||||
var propertyName = string.IsNullOrEmpty(PropertyName) ? $"[{index}]" : $"{PropertyName}[{index}]";
|
||||
|
||||
foreach (var step in Steps)
|
||||
{
|
||||
foreach (var problem in step.Validate(element, context, propertyName, Code, Severity))
|
||||
{
|
||||
yield return problem;
|
||||
}
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/request.validation/RuleBuilderExtensions.cs
Normal file
319
src/request.validation/RuleBuilderExtensions.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Provides built-in validators for common validation scenarios.
|
||||
/// </summary>
|
||||
public static class RuleBuilderExtensions
|
||||
{
|
||||
private static bool IsNull<TProperty>([NotNullWhen(false)] TProperty? value)
|
||||
{
|
||||
object? boxed = value;
|
||||
return boxed is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not null for reference types.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated. Must be a nullable reference type.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-null condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> NotNull<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule)
|
||||
where TProperty : class?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value is not null, "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not null for nullable value types.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The underlying non-nullable value type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-null condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty?> NotNull<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty?> rule)
|
||||
where TProperty : struct
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value.HasValue, "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value is not null, empty, or whitespace.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-empty condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> NotEmpty<T>(this IPropertyRuleBuilder<T, string?> rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => !string.IsNullOrWhiteSpace(value), "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the collection property value is not null and contains at least one element.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TElement">The type of the elements in the collection being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-empty condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, IEnumerable<TElement>?> NotEmpty<T, TElement>(
|
||||
this IPropertyRuleBuilder<T, IEnumerable<TElement>?> rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value meets the specified minimum length.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minLength">The minimum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the minimum length condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> MinLength<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int minLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.Length >= minLength,
|
||||
$"Value must be at least {minLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value does not exceed the specified maximum length.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="maxLength">The maximum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the maximum length condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> MaxLength<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int maxLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.Length <= maxLength,
|
||||
$"Value must be at most {maxLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value falls within the specified inclusive length range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minLength">The minimum allowed number of characters.</param>
|
||||
/// <param name="maxLength">The maximum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the length range condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Length<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int minLength,
|
||||
int maxLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
|
||||
|
||||
if (maxLength < minLength)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLength),
|
||||
"Maximum length must be greater than or equal to minimum length.");
|
||||
}
|
||||
|
||||
return rule.Must(value => IsNull(value) || (value.Length >= minLength && value.Length <= maxLength),
|
||||
$"Value must be between {minLength} and {maxLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is greater than the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be greater than.</param>
|
||||
/// <returns>The updated rule builder with the greater-than condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> GreaterThan<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0,
|
||||
$"Value must be greater than {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is greater than or equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be greater than or equal to.</param>
|
||||
/// <returns>The updated rule builder with the greater-than-or-equal condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> GreaterThanOrEqualTo<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0,
|
||||
$"Value must be greater than or equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is less than the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be less than.</param>
|
||||
/// <returns>The updated rule builder with the less-than condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> LessThan<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0,
|
||||
$"Value must be less than {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is less than or equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be less than or equal to.</param>
|
||||
/// <returns>The updated rule builder with the less-than-or-equal condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> LessThanOrEqualTo<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0,
|
||||
$"Value must be less than or equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value falls within the specified inclusive range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minValue">The minimum allowed value.</param>
|
||||
/// <param name="maxValue">The maximum allowed value.</param>
|
||||
/// <returns>The updated rule builder with the range condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> Between<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty minValue,
|
||||
TProperty maxValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
if (minValue is not null && maxValue is not null && minValue.CompareTo(maxValue) > 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxValue),
|
||||
"Maximum value must be greater than or equal to minimum value.");
|
||||
}
|
||||
|
||||
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0),
|
||||
$"Value must be between {minValue} and {maxValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The required value for the property.</param>
|
||||
/// <returns>The updated rule builder with the equality condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> Equal<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IEquatable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => EqualityComparer<TProperty>.Default.Equals(value, comparisonValue),
|
||||
$"Value must be equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not equal to the specified disallowed value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="disallowedValue">The value that the property must not equal.</param>
|
||||
/// <returns>The updated rule builder with the inequality condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> NotEqual<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty disallowedValue)
|
||||
where TProperty : IEquatable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => !EqualityComparer<TProperty>.Default.Equals(value, disallowedValue),
|
||||
$"Value must not be equal to {disallowedValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value matches the specified regular expression pattern.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="pattern">The regular expression pattern that the property value must match.</param>
|
||||
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Matches<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
string pattern)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentException.ThrowIfNullOrEmpty(pattern);
|
||||
|
||||
return rule.Must(value => IsNull(value) || Regex.IsMatch(value, pattern),
|
||||
"Value is not in the correct format.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value matches the specified regular expression.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="regex">The regular expression that the property value must match.</param>
|
||||
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Matches<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
Regex regex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentNullException.ThrowIfNull(regex);
|
||||
|
||||
return rule.Must(value => IsNull(value) || regex.IsMatch(value),
|
||||
"Value is not in the correct format.");
|
||||
}
|
||||
}
|
||||
79
src/request.validation/RuleStep.cs
Normal file
79
src/request.validation/RuleStep.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
internal interface IRuleStep<in TValue>
|
||||
{
|
||||
IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity);
|
||||
}
|
||||
|
||||
internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
|
||||
{
|
||||
private readonly Func<TValue, bool> _predicate;
|
||||
private readonly string _message;
|
||||
|
||||
public PredicateRuleStep(Func<TValue, bool> predicate, string message)
|
||||
{
|
||||
_predicate = predicate;
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
|
||||
{
|
||||
if (_predicate(value))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new Problem
|
||||
{
|
||||
PropertyName = propertyPath,
|
||||
Message = _message,
|
||||
Code = code,
|
||||
Severity = severity,
|
||||
AttemptedValue = value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ValidatorRuleStep<TValue> : IRuleStep<TValue>
|
||||
{
|
||||
private readonly Func<ValidationContext, IValidator> _resolver;
|
||||
|
||||
public ValidatorRuleStep(Func<ValidationContext, IValidator> resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var clone = new ValidationContext<object?>(value, context.ServiceProvider, context.Items);
|
||||
var validation = _resolver(context).Validate(clone);
|
||||
|
||||
foreach (var problem in validation.Problems)
|
||||
{
|
||||
yield return problem with { PropertyName = AppendPropertyPath(propertyPath, problem.PropertyName) };
|
||||
}
|
||||
}
|
||||
|
||||
private static string AppendPropertyPath(string prefix, string suffix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
return suffix;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(suffix))
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
return $"{prefix}.{suffix}";
|
||||
}
|
||||
}
|
||||
25
src/request.validation/Severity.cs
Normal file
25
src/request.validation/Severity.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the severity of a rule.
|
||||
/// </summary>
|
||||
public enum Severity
|
||||
{
|
||||
/// <summary>
|
||||
/// Error
|
||||
/// </summary>
|
||||
Error,
|
||||
|
||||
/// <summary>
|
||||
/// Warning
|
||||
/// </summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>
|
||||
/// Info
|
||||
/// </summary>
|
||||
Info,
|
||||
}
|
||||
32
src/request.validation/Validation.cs
Normal file
32
src/request.validation/Validation.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of executing one or more validations against a given object or request.
|
||||
/// </summary>
|
||||
public sealed class Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the result of executing one or more validations against a given object or request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class contains the list of validation problems identified during the validation process.
|
||||
/// A validation is considered successful if no problems are found.
|
||||
/// </remarks>
|
||||
public Validation(IEnumerable<Problem> problems)
|
||||
{
|
||||
Problems = [.. problems];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the validation was successful.
|
||||
/// </summary>
|
||||
public bool IsValid => Problems.Count is 0;
|
||||
|
||||
/// <summary>
|
||||
/// The problems that were found during validation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Problem> Problems { get; }
|
||||
}
|
||||
64
src/request.validation/ValidationContext.cs
Normal file
64
src/request.validation/ValidationContext.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the context in which validation occurs, providing information about the
|
||||
/// instance being validated, service provider, and additional items for use in validation.
|
||||
/// </summary>
|
||||
public abstract class ValidationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new validation context.
|
||||
/// </summary>
|
||||
/// <param name="instance">The object currently being validated.</param>
|
||||
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
|
||||
/// <param name="items">Per-call state shared across nested validation operations.</param>
|
||||
protected ValidationContext(object? instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
|
||||
{
|
||||
Instance = instance;
|
||||
ServiceProvider = serviceProvider;
|
||||
Items = items ?? ReadOnlyDictionary<object, object?>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The object currently being validated.
|
||||
/// </summary>
|
||||
public object? Instance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The service provider available for nested validator resolution.
|
||||
/// </summary>
|
||||
public IServiceProvider? ServiceProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-call state shared across nested validation operations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<object, object?> Items { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the context in which validation occurs, providing information about the
|
||||
/// instance being validated, service provider, and additional items for use in validation.
|
||||
/// </summary>
|
||||
public sealed class ValidationContext<T> : ValidationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new validation context.
|
||||
/// </summary>
|
||||
/// <param name="instance">The object currently being validated.</param>
|
||||
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
|
||||
/// <param name="items">Per-call state shared across nested validation operations.</param>
|
||||
public ValidationContext(T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
|
||||
: base(instance, serviceProvider, items)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The object currently being validated.
|
||||
/// </summary>
|
||||
public new T? Instance => base.Instance is T value ? value : default;
|
||||
}
|
||||
143
src/request.validation/Validator.cs
Normal file
143
src/request.validation/Validator.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the base class for defining validation logic for a specific type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object to be validated.</typeparam>
|
||||
public abstract class Validator<T> : IValidator<T>
|
||||
{
|
||||
private readonly List<Rule> _rules = [];
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validation rule for a specific property of the type being validated.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
|
||||
/// <param name="expression">An expression representing the property to validate.</param>
|
||||
/// <returns>An object that allows further configuration of the validation rule.</returns>
|
||||
public IPropertyRuleBuilder<T, TProperty> RuleFor<TProperty>(
|
||||
Expression<Func<T, TProperty>> expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
_rules.Add(new PropertyRule<T, TProperty>(expression));
|
||||
|
||||
return new PropertyRuleBuilder<TProperty>(_rules, _rules.Count - 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validation rule for each element in a collection property of the type being validated.
|
||||
/// </summary>
|
||||
/// <typeparam name="TElement">The type of the elements in the collection to validate.</typeparam>
|
||||
/// <param name="expression">An expression representing the collection property to validate.</param>
|
||||
/// <returns>An object that allows further configuration of the validation rule.</returns>
|
||||
public IPropertyRuleBuilder<T, TElement> RuleForEach<TElement>(
|
||||
Expression<Func<T, IEnumerable<TElement>>> expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
_rules.Add(new CollectionRule<T, TElement>(expression));
|
||||
|
||||
return new PropertyRuleBuilder<TElement>(_rules, _rules.Count - 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Validation Validate(T instance)
|
||||
{
|
||||
return Validate(new ValidationContext<T>(instance));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Validation Validate(ValidationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
return new Validation(_rules.SelectMany(rule => rule.Validate(context)));
|
||||
}
|
||||
|
||||
private static IValidator ResolveValidator<TValidator>(ValidationContext context)
|
||||
where TValidator : IValidator
|
||||
{
|
||||
if (context.ServiceProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' because the validation context has no service provider.");
|
||||
}
|
||||
|
||||
if (context.ServiceProvider.GetService(typeof(TValidator)) is not TValidator validator)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' from the validation context service provider.");
|
||||
}
|
||||
|
||||
return validator;
|
||||
}
|
||||
|
||||
private sealed class PropertyRuleBuilder<TProperty>
|
||||
: IPropertyRuleBuilder<T, TProperty>
|
||||
{
|
||||
private readonly List<Rule> _rules;
|
||||
private readonly int _index;
|
||||
|
||||
public PropertyRuleBuilder(List<Rule> rules, int index)
|
||||
{
|
||||
_rules = rules;
|
||||
_index = index;
|
||||
}
|
||||
|
||||
private Rule<T, TProperty> CurrentRule
|
||||
{
|
||||
get => (Rule<T, TProperty>)_rules[_index];
|
||||
set => _rules[_index] = value;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentException.ThrowIfNullOrEmpty(message);
|
||||
|
||||
var step = new PredicateRuleStep<TProperty>(predicate, message);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(validator);
|
||||
|
||||
var step = new ValidatorRuleStep<TProperty>(_ => validator);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator
|
||||
{
|
||||
var step = new ValidatorRuleStep<TProperty>(ResolveValidator<TValidator>);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> WithCode(string code)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(code);
|
||||
|
||||
CurrentRule = CurrentRule with { Code = code };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
|
||||
{
|
||||
CurrentRule = CurrentRule with { Severity = severity };
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/request.validation/package-icon.png
Normal file
BIN
src/request.validation/package-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
85
src/request.validation/package-readme.md
Normal file
85
src/request.validation/package-readme.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
Request.Validation is a lightweight validation library for C#, built around composable validators, fluent rules, and
|
||||
structured validation results.
|
||||
|
||||
## Features
|
||||
|
||||
- **Composable validators:** Build validators by inheriting from `Validator<T>` and defining rules with `RuleFor` and
|
||||
`RuleForEach`.
|
||||
- **Built-in and custom rules:** Use helpers like `NotEmpty`, `Length`, `Between`, and `Matches`, or define custom
|
||||
predicates with `Must`.
|
||||
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
|
||||
code, and attempted value.
|
||||
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Install the NuGet package:
|
||||
|
||||
```shell
|
||||
dotnet add package Geekeey.Request.Validation
|
||||
```
|
||||
|
||||
You may need to add our NuGet feed to your nuget.config this can be done by running the following command:
|
||||
|
||||
```shell
|
||||
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```csharp
|
||||
using Geekeey.Request.Validation;
|
||||
|
||||
public sealed record Address(string? Street);
|
||||
|
||||
public sealed record CreateUserRequest(
|
||||
string? Name,
|
||||
int Age,
|
||||
Address? Address,
|
||||
IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed class AddressValidator : Validator<Address>
|
||||
{
|
||||
public AddressValidator()
|
||||
{
|
||||
RuleFor(address => address.Street)
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateUserRequestValidator : Validator<CreateUserRequest>
|
||||
{
|
||||
public CreateUserRequestValidator()
|
||||
{
|
||||
RuleFor(request => request.Name)
|
||||
.NotEmpty()
|
||||
.Length(2, 100)
|
||||
.WithCode("NAME_INVALID");
|
||||
|
||||
RuleFor(request => request.Age)
|
||||
.Between(18, 120);
|
||||
|
||||
RuleFor(request => request.Address)
|
||||
.SetValidator(new AddressValidator());
|
||||
|
||||
RuleForEach(request => request.Tags)
|
||||
.NotEmpty()
|
||||
.WithSeverity(Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
var validator = new CreateUserRequestValidator();
|
||||
var validation = validator.Validate(new CreateUserRequest(
|
||||
Name: "",
|
||||
Age: 16,
|
||||
Address: new Address(""),
|
||||
Tags: ["", "admin"]));
|
||||
|
||||
foreach (var problem in validation.Problems)
|
||||
{
|
||||
Console.WriteLine($"{problem.PropertyName}: {problem.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
The resulting `Problem` entries include full property paths like `Address.Street` and `Tags[0]`, making it easy to
|
||||
surface validation errors back to callers or APIs.
|
||||
Loading…
Add table
Add a link
Reference in a new issue