diff --git a/request.slnx b/request.slnx
index 0cadb86..aa2a0cb 100644
--- a/request.slnx
+++ b/request.slnx
@@ -1,6 +1,8 @@
+
+
-
+
\ No newline at end of file
diff --git a/src/request.validation.tests/.editorconfig b/src/request.validation.tests/.editorconfig
new file mode 100644
index 0000000..9de929c
--- /dev/null
+++ b/src/request.validation.tests/.editorconfig
@@ -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
diff --git a/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj b/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj
new file mode 100644
index 0000000..cee590b
--- /dev/null
+++ b/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs
new file mode 100644
index 0000000..476f5c5
--- /dev/null
+++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs
@@ -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(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(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(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?>(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(model
+ => model.Value, rule => rule.Length(5, 4)))
+ .Throws();
+ }
+
+ [Test]
+ public async Task I_see_it_throw_for_negative_min_length_configuration()
+ {
+ await Assert.That(() => new PropertyValidator(model
+ => model.Value, rule => rule.MinLength(-1)))
+ .Throws();
+ }
+
+ [Test]
+ public async Task I_see_it_throw_for_invalid_between_configuration()
+ {
+ await Assert.That(() => new PropertyValidator(model
+ => model.Value, rule => rule.Between(10, 0)))
+ .Throws();
+ }
+
+ 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs
new file mode 100644
index 0000000..09fe3c1
--- /dev/null
+++ b/src/request.validation.tests/ValidatorTests.cs
@@ -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(
+ 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(
+ 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 => 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(
+ 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 => 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 => person.Name!.Trim().Length,
+ rule => rule.GreaterThan(0)))
+ .Throws();
+ }
+
+ [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(
+ 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(
+ 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 => 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 => 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(
+ person => person.Address,
+ rule => rule.SetValidator>());
+ var context = new ValidationContext(
+ 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