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 0000000..35f4099 Binary files /dev/null and b/src/request.validation/package-icon.png differ 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