From de2d0e269307159f15c8695864348d44acf0057c Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 16 May 2026 09:49:04 +0200 Subject: [PATCH] feat: add request validation --- request.slnx | 4 +- src/request.validation.tests/.editorconfig | 15 + .../Geekeey.Request.Validation.Tests.csproj | 22 ++ .../RuleBuilderExtensionsTests.cs | 326 ++++++++++++++++++ .../ValidatorTests.cs | 279 +++++++++++++++ .../_fixtures/Address.cs | 9 + .../_fixtures/CollectionValidator.cs | 19 + .../_fixtures/CollectionValueModel.cs | 9 + .../_fixtures/IntValueModel.cs | 9 + .../_fixtures/Member.cs | 9 + .../_fixtures/NullableIntValueModel.cs | 9 + .../_fixtures/NullableTeam.cs | 9 + .../_fixtures/OrderedFailuresValidator.cs | 17 + .../_fixtures/Person.cs | 9 + .../_fixtures/PersonWithAddress.cs | 9 + .../_fixtures/PropertyValidator.cs | 19 + .../_fixtures/ReferenceValueModel.cs | 9 + .../_fixtures/StringValueModel.cs | 9 + .../_fixtures/Team.cs | 9 + .../Geekeey.Request.Validation.csproj | 30 ++ .../IPropertyRuleBuilder.cs | 48 +++ src/request.validation/IValidator.cs | 31 ++ src/request.validation/Problem.cs | 35 ++ src/request.validation/Rule.cs | 139 ++++++++ .../RuleBuilderExtensions.cs | 319 +++++++++++++++++ src/request.validation/RuleStep.cs | 79 +++++ src/request.validation/Severity.cs | 25 ++ src/request.validation/Validation.cs | 32 ++ src/request.validation/ValidationContext.cs | 64 ++++ src/request.validation/Validator.cs | 143 ++++++++ src/request.validation/package-icon.png | Bin 0 -> 15556 bytes src/request.validation/package-readme.md | 85 +++++ 32 files changed, 1829 insertions(+), 1 deletion(-) create mode 100644 src/request.validation.tests/.editorconfig create mode 100644 src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj create mode 100644 src/request.validation.tests/RuleBuilderExtensionsTests.cs create mode 100644 src/request.validation.tests/ValidatorTests.cs create mode 100644 src/request.validation.tests/_fixtures/Address.cs create mode 100644 src/request.validation.tests/_fixtures/CollectionValidator.cs create mode 100644 src/request.validation.tests/_fixtures/CollectionValueModel.cs create mode 100644 src/request.validation.tests/_fixtures/IntValueModel.cs create mode 100644 src/request.validation.tests/_fixtures/Member.cs create mode 100644 src/request.validation.tests/_fixtures/NullableIntValueModel.cs create mode 100644 src/request.validation.tests/_fixtures/NullableTeam.cs create mode 100644 src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs create mode 100644 src/request.validation.tests/_fixtures/Person.cs create mode 100644 src/request.validation.tests/_fixtures/PersonWithAddress.cs create mode 100644 src/request.validation.tests/_fixtures/PropertyValidator.cs create mode 100644 src/request.validation.tests/_fixtures/ReferenceValueModel.cs create mode 100644 src/request.validation.tests/_fixtures/StringValueModel.cs create mode 100644 src/request.validation.tests/_fixtures/Team.cs create mode 100644 src/request.validation/Geekeey.Request.Validation.csproj create mode 100644 src/request.validation/IPropertyRuleBuilder.cs create mode 100644 src/request.validation/IValidator.cs create mode 100644 src/request.validation/Problem.cs create mode 100644 src/request.validation/Rule.cs create mode 100644 src/request.validation/RuleBuilderExtensions.cs create mode 100644 src/request.validation/RuleStep.cs create mode 100644 src/request.validation/Severity.cs create mode 100644 src/request.validation/Validation.cs create mode 100644 src/request.validation/ValidationContext.cs create mode 100644 src/request.validation/Validator.cs create mode 100644 src/request.validation/package-icon.png create mode 100644 src/request.validation/package-readme.md 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(new object()); + + await Assert.That(() => validator.Validate(context)).Throws(); + } + + [Test] + public async Task I_see_it_throw_when_a_context_resolved_validator_cannot_be_resolved() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.SetValidator>()); + var context = new ValidationContext( + new PersonWithAddress { Address = new Address { Street = "" } }); + + await Assert.That(() => validator.Validate(context)).Throws(); + } + + private static PropertyValidator CreatePersonValidator() + { + return new PropertyValidator( + person => person.Name, + rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Name is required.") + .WithCode("NAME_REQUIRED") + .WithSeverity(Severity.Warning)); + } + + private static PropertyValidator CreateAddressValidator() + { + return new PropertyValidator( + address => address.Street, + rule => rule.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required.")); + } + + private static PropertyValidator CreateMemberValidator() + { + return new PropertyValidator( + member => member.Name, + rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required.")); + } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/Address.cs b/src/request.validation.tests/_fixtures/Address.cs new file mode 100644 index 0000000..5053c19 --- /dev/null +++ b/src/request.validation.tests/_fixtures/Address.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/CollectionValidator.cs b/src/request.validation.tests/_fixtures/CollectionValidator.cs new file mode 100644 index 0000000..24652f6 --- /dev/null +++ b/src/request.validation.tests/_fixtures/CollectionValidator.cs @@ -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 : Validator +{ + public CollectionValidator( + Expression>> expression, + Action> configure) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(configure); + + configure(RuleForEach(expression)); + } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/CollectionValueModel.cs b/src/request.validation.tests/_fixtures/CollectionValueModel.cs new file mode 100644 index 0000000..b02f998 --- /dev/null +++ b/src/request.validation.tests/_fixtures/CollectionValueModel.cs @@ -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? Value { get; init; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/IntValueModel.cs b/src/request.validation.tests/_fixtures/IntValueModel.cs new file mode 100644 index 0000000..116551e --- /dev/null +++ b/src/request.validation.tests/_fixtures/IntValueModel.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/Member.cs b/src/request.validation.tests/_fixtures/Member.cs new file mode 100644 index 0000000..4873e68 --- /dev/null +++ b/src/request.validation.tests/_fixtures/Member.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/NullableIntValueModel.cs b/src/request.validation.tests/_fixtures/NullableIntValueModel.cs new file mode 100644 index 0000000..e3b7def --- /dev/null +++ b/src/request.validation.tests/_fixtures/NullableIntValueModel.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/NullableTeam.cs b/src/request.validation.tests/_fixtures/NullableTeam.cs new file mode 100644 index 0000000..8c4341b --- /dev/null +++ b/src/request.validation.tests/_fixtures/NullableTeam.cs @@ -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? Members { get; init; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs b/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs new file mode 100644 index 0000000..3924540 --- /dev/null +++ b/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs @@ -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 +{ + public OrderedFailuresValidator() + { + RuleFor(model => model.Value) + .Must(_ => false, "First failure.") + .Must(_ => false, "Second failure."); + + RuleFor(model => model.Value) + .Must(_ => false, "Third failure."); + } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/Person.cs b/src/request.validation.tests/_fixtures/Person.cs new file mode 100644 index 0000000..7eb8aed --- /dev/null +++ b/src/request.validation.tests/_fixtures/Person.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/PersonWithAddress.cs b/src/request.validation.tests/_fixtures/PersonWithAddress.cs new file mode 100644 index 0000000..d033aae --- /dev/null +++ b/src/request.validation.tests/_fixtures/PersonWithAddress.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/PropertyValidator.cs b/src/request.validation.tests/_fixtures/PropertyValidator.cs new file mode 100644 index 0000000..9e9fe89 --- /dev/null +++ b/src/request.validation.tests/_fixtures/PropertyValidator.cs @@ -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 : Validator +{ + public PropertyValidator( + Expression> expression, + Action> configure) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(configure); + + configure(RuleFor(expression)); + } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/ReferenceValueModel.cs b/src/request.validation.tests/_fixtures/ReferenceValueModel.cs new file mode 100644 index 0000000..7d08948 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ReferenceValueModel.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/StringValueModel.cs b/src/request.validation.tests/_fixtures/StringValueModel.cs new file mode 100644 index 0000000..43eb073 --- /dev/null +++ b/src/request.validation.tests/_fixtures/StringValueModel.cs @@ -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; } +} \ No newline at end of file diff --git a/src/request.validation.tests/_fixtures/Team.cs b/src/request.validation.tests/_fixtures/Team.cs new file mode 100644 index 0000000..9e166fe --- /dev/null +++ b/src/request.validation.tests/_fixtures/Team.cs @@ -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 Members { get; init; } = []; +} \ No newline at end of file diff --git a/src/request.validation/Geekeey.Request.Validation.csproj b/src/request.validation/Geekeey.Request.Validation.csproj new file mode 100644 index 0000000..b86acab --- /dev/null +++ b/src/request.validation/Geekeey.Request.Validation.csproj @@ -0,0 +1,30 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.validation + EUPL-1.2 + + + + + + + + + \ No newline at end of file diff --git a/src/request.validation/IPropertyRuleBuilder.cs b/src/request.validation/IPropertyRuleBuilder.cs new file mode 100644 index 0000000..1ba7486 --- /dev/null +++ b/src/request.validation/IPropertyRuleBuilder.cs @@ -0,0 +1,48 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents a builder for defining a validation rule for a specific property of a type. +/// +/// The type of the object to be validated. +/// The type of the property to validate. +public interface IPropertyRuleBuilder +{ + /// + /// Defines a validation rule that must be met for the property to be considered valid. + /// + /// The predicate function that determines if the property value is valid. + /// The error message to be returned if the validation fails. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder Must(Func predicate, string message); + + /// + /// Sets the validator to be used for validating the property value. + /// + /// The validator instance to use for validation. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder SetValidator(IValidator validator); + + /// + /// Sets the validator to be used for validating the property value. + /// + /// The type of the validator to use for validation. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder SetValidator() where TValidator : IValidator; + + /// + /// Sets the error code for the validation rule. + /// + /// The error code to be associated with the validation rule. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithCode(string code); + + /// + /// Sets the severity of the validation rule. + /// + /// The severity level of the validation rule. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithSeverity(Severity severity); +} \ No newline at end of file diff --git a/src/request.validation/IValidator.cs b/src/request.validation/IValidator.cs new file mode 100644 index 0000000..1f00d29 --- /dev/null +++ b/src/request.validation/IValidator.cs @@ -0,0 +1,31 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Defines a validator for a particular type. +/// +public interface IValidator +{ + /// + /// Performs validation on the provided and returns the result. + /// + /// The validation context containing the instance to validate, service provider, and additional items. + /// A object containing the results of the validation, including any problems encountered. + Validation Validate(ValidationContext context); +} + +/// +/// Defines a validator for a particular type. +/// +/// The type of the instance to validate. +public interface IValidator : IValidator +{ + /// + /// Executes the validation logic for a specified instance of type and returns the validation result. + /// + /// The instance of type to validate. + /// A object containing the results of the validation, including any problems encountered. + Validation Validate(T instance); +} \ No newline at end of file diff --git a/src/request.validation/Problem.cs b/src/request.validation/Problem.cs new file mode 100644 index 0000000..6624462 --- /dev/null +++ b/src/request.validation/Problem.cs @@ -0,0 +1,35 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents a validation problem identified during a request validation process. +/// +public record Problem +{ + /// + /// The name of the property. + /// + public required string PropertyName { get; init; } + + /// + /// Custom severity level associated with the failure. + /// + public Severity Severity { get; init; } = Severity.Error; + + /// + /// The error message + /// + public required string Message { get; init; } + + /// + /// Gets or sets the error code. + /// + public string? Code { get; init; } + + /// + /// The property value that caused the failure. + /// + public object? AttemptedValue { get; init; } +} \ No newline at end of file diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs new file mode 100644 index 0000000..459ea57 --- /dev/null +++ b/src/request.validation/Rule.cs @@ -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 Validate(ValidationContext context); +} + +internal abstract record Rule : Rule +{ + protected Rule(Expression expression) + { + PropertyName = GetPropertyPath(expression); + } + + public string PropertyName { get; } + + public IReadOnlyList> Steps { get; init; } = []; + + private static string GetPropertyPath(Expression expression) + { + var members = new Stack(); + + 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 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 Validate(T instance, ValidationContext context); +} + +internal sealed record PropertyRule : Rule +{ + private readonly Func _accessor; + + public PropertyRule(Expression> expression) : base(expression) + { + _accessor = expression.Compile(); + } + + protected override IEnumerable 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 : Rule +{ + private readonly Func> _accessor; + + public CollectionRule(Expression>> expression) : base(expression) + { + _accessor = expression.Compile(); + } + + protected override IEnumerable 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++; + } + } +} \ No newline at end of file diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs new file mode 100644 index 0000000..4f76ada --- /dev/null +++ b/src/request.validation/RuleBuilderExtensions.cs @@ -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; + +/// +/// Provides built-in validators for common validation scenarios. +/// +public static class RuleBuilderExtensions +{ + private static bool IsNull([NotNullWhen(false)] TProperty? value) + { + object? boxed = value; + return boxed is null; + } + + /// + /// Adds a rule to ensure that the property value is not null for reference types. + /// + /// The type of the object being validated. + /// The type of the property being validated. Must be a nullable reference type. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-null condition added. + public static IPropertyRuleBuilder NotNull( + this IPropertyRuleBuilder rule) + where TProperty : class? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value is not null, "Value is required."); + } + + /// + /// Adds a rule to ensure that the property value is not null for nullable value types. + /// + /// The type of the object being validated. + /// The underlying non-nullable value type of the property being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-null condition added. + public static IPropertyRuleBuilder NotNull( + this IPropertyRuleBuilder rule) + where TProperty : struct + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value.HasValue, "Value is required."); + } + + /// + /// Adds a rule to ensure that the string property value is not null, empty, or whitespace. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-empty condition added. + public static IPropertyRuleBuilder NotEmpty(this IPropertyRuleBuilder rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => !string.IsNullOrWhiteSpace(value), "Value is required."); + } + + /// + /// Adds a rule to ensure that the collection property value is not null and contains at least one element. + /// + /// The type of the object being validated. + /// The type of the elements in the collection being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-empty condition added. + public static IPropertyRuleBuilder?> NotEmpty( + this IPropertyRuleBuilder?> rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value is not null && value.Any(), "Value is required."); + } + + /// + /// Adds a rule to ensure that the string property value meets the specified minimum length. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed number of characters. + /// The updated rule builder with the minimum length condition added. + public static IPropertyRuleBuilder MinLength( + this IPropertyRuleBuilder 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."); + } + + /// + /// Adds a rule to ensure that the string property value does not exceed the specified maximum length. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The maximum allowed number of characters. + /// The updated rule builder with the maximum length condition added. + public static IPropertyRuleBuilder MaxLength( + this IPropertyRuleBuilder 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."); + } + + /// + /// Adds a rule to ensure that the string property value falls within the specified inclusive length range. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed number of characters. + /// The maximum allowed number of characters. + /// The updated rule builder with the length range condition added. + public static IPropertyRuleBuilder Length( + this IPropertyRuleBuilder 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."); + } + + /// + /// Adds a rule to ensure that the property value is greater than the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be greater than. + /// The updated rule builder with the greater-than condition added. + public static IPropertyRuleBuilder GreaterThan( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0, + $"Value must be greater than {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is greater than or equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be greater than or equal to. + /// The updated rule builder with the greater-than-or-equal condition added. + public static IPropertyRuleBuilder GreaterThanOrEqualTo( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0, + $"Value must be greater than or equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is less than the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be less than. + /// The updated rule builder with the less-than condition added. + public static IPropertyRuleBuilder LessThan( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0, + $"Value must be less than {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is less than or equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be less than or equal to. + /// The updated rule builder with the less-than-or-equal condition added. + public static IPropertyRuleBuilder LessThanOrEqualTo( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0, + $"Value must be less than or equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value falls within the specified inclusive range. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed value. + /// The maximum allowed value. + /// The updated rule builder with the range condition added. + public static IPropertyRuleBuilder Between( + this IPropertyRuleBuilder rule, + TProperty minValue, + TProperty maxValue) + where TProperty : IComparable? + { + 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}."); + } + + /// + /// Adds a rule to ensure that the property value is equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The required value for the property. + /// The updated rule builder with the equality condition added. + public static IPropertyRuleBuilder Equal( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IEquatable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => EqualityComparer.Default.Equals(value, comparisonValue), + $"Value must be equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is not equal to the specified disallowed value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must not equal. + /// The updated rule builder with the inequality condition added. + public static IPropertyRuleBuilder NotEqual( + this IPropertyRuleBuilder rule, + TProperty disallowedValue) + where TProperty : IEquatable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => !EqualityComparer.Default.Equals(value, disallowedValue), + $"Value must not be equal to {disallowedValue}."); + } + + /// + /// Adds a rule to ensure that the string property value matches the specified regular expression pattern. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The regular expression pattern that the property value must match. + /// The updated rule builder with the pattern-matching condition added. + public static IPropertyRuleBuilder Matches( + this IPropertyRuleBuilder 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."); + } + + /// + /// Adds a rule to ensure that the string property value matches the specified regular expression. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The regular expression that the property value must match. + /// The updated rule builder with the pattern-matching condition added. + public static IPropertyRuleBuilder Matches( + this IPropertyRuleBuilder 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."); + } +} \ No newline at end of file diff --git a/src/request.validation/RuleStep.cs b/src/request.validation/RuleStep.cs new file mode 100644 index 0000000..5259e1a --- /dev/null +++ b/src/request.validation/RuleStep.cs @@ -0,0 +1,79 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +internal interface IRuleStep +{ + IEnumerable Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity); +} + +internal sealed record PredicateRuleStep : IRuleStep +{ + private readonly Func _predicate; + private readonly string _message; + + public PredicateRuleStep(Func predicate, string message) + { + _predicate = predicate; + _message = message; + } + + public IEnumerable 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 : IRuleStep +{ + private readonly Func _resolver; + + public ValidatorRuleStep(Func resolver) + { + _resolver = resolver; + } + + public IEnumerable Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity) + { + if (value is null) + { + yield break; + } + + var clone = new ValidationContext(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}"; + } +} \ No newline at end of file diff --git a/src/request.validation/Severity.cs b/src/request.validation/Severity.cs new file mode 100644 index 0000000..b898fff --- /dev/null +++ b/src/request.validation/Severity.cs @@ -0,0 +1,25 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Specifies the severity of a rule. +/// +public enum Severity +{ + /// + /// Error + /// + Error, + + /// + /// Warning + /// + Warning, + + /// + /// Info + /// + Info, +} \ No newline at end of file diff --git a/src/request.validation/Validation.cs b/src/request.validation/Validation.cs new file mode 100644 index 0000000..0f9a3bf --- /dev/null +++ b/src/request.validation/Validation.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents the result of executing one or more validations against a given object or request. +/// +public sealed class Validation +{ + /// + /// Represents the result of executing one or more validations against a given object or request. + /// + /// + /// This class contains the list of validation problems identified during the validation process. + /// A validation is considered successful if no problems are found. + /// + public Validation(IEnumerable problems) + { + Problems = [.. problems]; + } + + /// + /// Whether the validation was successful. + /// + public bool IsValid => Problems.Count is 0; + + /// + /// The problems that were found during validation. + /// + public IReadOnlyList Problems { get; } +} \ No newline at end of file diff --git a/src/request.validation/ValidationContext.cs b/src/request.validation/ValidationContext.cs new file mode 100644 index 0000000..30973ba --- /dev/null +++ b/src/request.validation/ValidationContext.cs @@ -0,0 +1,64 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections.ObjectModel; + +namespace Geekeey.Request.Validation; + +/// +/// Represents the context in which validation occurs, providing information about the +/// instance being validated, service provider, and additional items for use in validation. +/// +public abstract class ValidationContext +{ + /// + /// Creates a new validation context. + /// + /// The object currently being validated. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + protected ValidationContext(object? instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + { + Instance = instance; + ServiceProvider = serviceProvider; + Items = items ?? ReadOnlyDictionary.Empty; + } + + /// + /// The object currently being validated. + /// + public object? Instance { get; } + + /// + /// The service provider available for nested validator resolution. + /// + public IServiceProvider? ServiceProvider { get; } + + /// + /// Per-call state shared across nested validation operations. + /// + public IReadOnlyDictionary Items { get; } +} + +/// +/// Represents the context in which validation occurs, providing information about the +/// instance being validated, service provider, and additional items for use in validation. +/// +public sealed class ValidationContext : ValidationContext +{ + /// + /// Creates a new validation context. + /// + /// The object currently being validated. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + public ValidationContext(T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + : base(instance, serviceProvider, items) + { + } + + /// + /// The object currently being validated. + /// + public new T? Instance => base.Instance is T value ? value : default; +} \ No newline at end of file diff --git a/src/request.validation/Validator.cs b/src/request.validation/Validator.cs new file mode 100644 index 0000000..3aadd22 --- /dev/null +++ b/src/request.validation/Validator.cs @@ -0,0 +1,143 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Validation; + +/// +/// Represents the base class for defining validation logic for a specific type. +/// +/// The type of the object to be validated. +public abstract class Validator : IValidator +{ + private readonly List _rules = []; + + /// + /// Defines a validation rule for a specific property of the type being validated. + /// + /// The type of the property to validate. + /// An expression representing the property to validate. + /// An object that allows further configuration of the validation rule. + public IPropertyRuleBuilder RuleFor( + Expression> expression) + { + ArgumentNullException.ThrowIfNull(expression); + + _rules.Add(new PropertyRule(expression)); + + return new PropertyRuleBuilder(_rules, _rules.Count - 1); + } + + /// + /// Defines a validation rule for each element in a collection property of the type being validated. + /// + /// The type of the elements in the collection to validate. + /// An expression representing the collection property to validate. + /// An object that allows further configuration of the validation rule. + public IPropertyRuleBuilder RuleForEach( + Expression>> expression) + { + ArgumentNullException.ThrowIfNull(expression); + + _rules.Add(new CollectionRule(expression)); + + return new PropertyRuleBuilder(_rules, _rules.Count - 1); + } + + /// + public Validation Validate(T instance) + { + return Validate(new ValidationContext(instance)); + } + + /// + public Validation Validate(ValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return new Validation(_rules.SelectMany(rule => rule.Validate(context))); + } + + private static IValidator ResolveValidator(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 + : IPropertyRuleBuilder + { + private readonly List _rules; + private readonly int _index; + + public PropertyRuleBuilder(List rules, int index) + { + _rules = rules; + _index = index; + } + + private Rule CurrentRule + { + get => (Rule)_rules[_index]; + set => _rules[_index] = value; + } + + public IPropertyRuleBuilder Must(Func predicate, string message) + { + ArgumentNullException.ThrowIfNull(predicate); + ArgumentException.ThrowIfNullOrEmpty(message); + + var step = new PredicateRuleStep(predicate, message); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder SetValidator(IValidator validator) + { + ArgumentNullException.ThrowIfNull(validator); + + var step = new ValidatorRuleStep(_ => validator); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder SetValidator() where TValidator : IValidator + { + var step = new ValidatorRuleStep(ResolveValidator); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder WithCode(string code) + { + ArgumentException.ThrowIfNullOrEmpty(code); + + CurrentRule = CurrentRule with { Code = code }; + + return this; + } + + public IPropertyRuleBuilder WithSeverity(Severity severity) + { + CurrentRule = CurrentRule with { Severity = severity }; + + return this; + } + } +} \ No newline at end of file diff --git a/src/request.validation/package-icon.png b/src/request.validation/package-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..35f4099b1fc44cc33936dcc321249cfab4de8c52 GIT binary patch literal 15556 zcmZ8|Wl$W>59l3o9PaM!aJai`arffx?p7%7P~4?hp%jNhin~K`FD}LH{r>O6`|xHb zn@M(db|=YXce6>XnyMTs5)l#r06G1TE0j#aIO=dAim{>$DNM7e zO(p1m2b+?^nc$G&&cwG$)oK7TjCL|*=qoc{3BF-qNhZ^kQSv<$SYLK8<$aES7|!#k zYnkp2ONN7FH3#?J2?aGTlX!4%Jr41Obvs7$if9Uk|xFq)_!m#i~1&t_Z*?m1^ zxN>c?w9CmP&?9(+H{=Vk(FgKWz2DxmbMQ^}F&h~++z|GdIGM()`Ut>@L ztJV45t+)S9lhAoSdfWW;@n)%LtR(cY;~^;cj{Y)n=WYvU!y^+w((L+_yogQs| zxhhw92bDZz!$h869~A&dC2K27JH)Tw+7$HOU*Sz8Q&|x{0pv9m(ob^jypEfE{D^bz zeQ5jCkD=C}+w*=+QupHLvGcT0%fr2z^f!zPlNSxxWj^kGb1gI6`&0g3;)m;Wz=l2L zPR4ZW{E^UgWyR)4+-LunPr`|Z0prg+|Jj}I==We&X|LYDl?Vkz<{Q_ZH`m1TIs8cvZ8;*NJbx|&u1fDO z4@gbx{@Yr}1NQV+s>;{a-+>?BeSY2lxjz(4B)e|g0XMe%P5E~f%xxn!u)a24@cy8? zwVT{%B#qbXK@wApC%hvXb|p*i@NuBR-V_ut{-S*kxOMiX07_PX_;L_jt1!UwGHBr^kOwddc@|_CC#h_fe8-c$iL%dmOAr&sHHchiUv0 zdi6jNq#7ZEo=IL5N^bl&cI zv2lp30B++l0*2z572L{Ubh#+Ql5rp?K3Ejh;*=D+12DjXAJg(ngt@#ad_s!sJoNY? z9|%iF0Sen$ZSQ}k-$VOVOtH+T|7>Ee76_62x#n+zUvETRZ$vC(HT#TcaJk-^OybKk z*Jt{+mo;s}cjt2aZ*A#ycLNSO)2CGoQ+1d{kHU@EwwLPG!{JL`Y)p{pQM3WfL@qK0 zRKwSPrtmNFD1sM9hMf$PL2g~#NpIW!!n)XP5V`gA(Ip~!bA6e7CxY&ZUH|1{YS_At z%-5}?A3g4_0atf*x+r?*W^IB&G( zp*^_GpO_3EgL#Rda#3VTh9s>8e`{a5<=)3gpP6=&Hm{;E2Air z{k=fUTM_@(jCP6!b_$Jia__kHUzutU-bM6z|A+kkY{1U@bcc8PsomLwndHycl~t4N zDL2xsGjxhZDsEsghjN7D-`X4cBUjb zg0jplpv(^qr{snT!V2>!7sutS-qN{Fzuu91--^KU_uU=S8~4quQu|=N_r_Rr()Mxl zbBAGO&t{uX(mMJM_z!@c!>X$l?Ac8*-fj-_RmDC{h<%Kt9PF>?#7)W!8o{+D0k;CS z9hEUL!==EH5c5ZI1_uOwaX<`%g+}1v3Jm^qWt($zgxp%;kGQ!A9vH-jVmcZQBEF8}W<)9Eyx` zBf#Bg(-yr$Y<3dH16@+y+6$8t-I2KX`hR6MNN>#J;Nxww;PuaCG6n!m8yLh%kma5W z^F+)6FIj6wYVtO=u$$SO{D^N4H2n3I0DA<-oUv`gl5{KhkTF{@+ym}XuIod@<;@(Hj`a-d7N2Y5#MbRMd|LNd+|=U4q`PL39A-K8NsDG@L976}=fO?(4>R zK5?6T7>5cyHu$*mq68!*5FU{J_eAKW=&csXrk

=twwFDw|u_7oe*s3ai1%@+b0K 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 OKtV=Tx?a*O{Qm&xB7La< literal 0 HcmV?d00001 diff --git a/src/request.validation/package-readme.md b/src/request.validation/package-readme.md new file mode 100644 index 0000000..2645a05 --- /dev/null +++ b/src/request.validation/package-readme.md @@ -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` 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 Tags); + +public sealed class AddressValidator : Validator

+{ + public AddressValidator() + { + RuleFor(address => address.Street) + .NotEmpty(); + } +} + +public sealed class CreateUserRequestValidator : Validator +{ + 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. \ No newline at end of file