feat: add request validation

This commit is contained in:
Louis Seubert 2026-05-16 09:49:04 +02:00
commit de2d0e2693
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
32 changed files with 1829 additions and 1 deletions

View file

@ -0,0 +1,15 @@
[*.{cs,vb}]
# disable CA1822: Mark members as static
# -> TUnit requiring instance methods for test cases
dotnet_diagnostic.CA1822.severity = none
# disable CA1707: Identifiers should not contain underscores
dotnet_diagnostic.CA1707.severity = none
# disable IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = none
# disable IDE0005: Unnecessary using directive
dotnet_diagnostic.IDE0005.severity = none
# disable IDE0390: Method can be made synchronous
dotnet_diagnostic.IDE0390.severity = none
# disable IDE0391: Method can be made synchronous
dotnet_diagnostic.IDE0391.severity = none

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.validation\Geekeey.Request.Validation.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,326 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.RegularExpressions;
namespace Geekeey.Request.Validation.Tests;
internal sealed class RuleBuilderExtensionsTests
{
[Test]
public async Task I_can_validate_not_null_for_reference_types()
{
var validator = new PropertyValidator<ReferenceValueModel, object?>(model =>
model.Value, rule => rule.NotNull());
var invalid = validator.Validate(new ReferenceValueModel());
var valid = validator.Validate(new ReferenceValueModel { Value = new object() });
await AssertSingleProblem(invalid, nameof(ReferenceValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_null_for_nullable_value_types()
{
var validator = new PropertyValidator<NullableIntValueModel, int?>(model =>
model.Value, rule => rule.NotNull());
var invalid = validator.Validate(new NullableIntValueModel());
var valid = validator.Validate(new NullableIntValueModel { Value = 1 });
await AssertSingleProblem(invalid, nameof(NullableIntValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_empty_for_strings()
{
var validator = new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.NotEmpty());
var invalid = validator.Validate(new StringValueModel { Value = " " });
var valid = validator.Validate(new StringValueModel { Value = "abc" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_empty_for_collections()
{
var validator = new PropertyValidator<CollectionValueModel, IEnumerable<string>?>(model =>
model.Value, rule => rule.NotEmpty());
var invalid = validator.Validate(new CollectionValueModel { Value = [] });
var valid = validator.Validate(new CollectionValueModel { Value = ["item"] });
await AssertSingleProblem(invalid, nameof(CollectionValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_min_length()
{
var validator =
new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.MinLength(3));
var invalid = validator.Validate(new StringValueModel { Value = "ab" });
var valid = validator.Validate(new StringValueModel { Value = "abc" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at least 3 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_max_length()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.MaxLength(5));
var invalid = validator.Validate(new StringValueModel { Value = "abcdef" });
var valid = validator.Validate(new StringValueModel { Value = "abcde" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at most 5 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_length()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Length(2, 4));
var invalid = validator.Validate(new StringValueModel { Value = "a" });
var valid = validator.Validate(new StringValueModel { Value = "ab" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value),
"Value must be between 2 and 4 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.GreaterThan(18));
var invalid = validator.Validate(new IntValueModel { Value = 18 });
var valid = validator.Validate(new IntValueModel { Value = 19 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than 18.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(1));
var invalid = validator.Validate(new IntValueModel { Value = 0 });
var valid = validator.Validate(new IntValueModel { Value = 1 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than or equal to 1.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.LessThan(10));
var invalid = validator.Validate(new IntValueModel { Value = 10 });
var valid = validator.Validate(new IntValueModel { Value = 9 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than 10.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.LessThanOrEqualTo(5));
var invalid = validator.Validate(new IntValueModel { Value = 6 });
var valid = validator.Validate(new IntValueModel { Value = 5 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than or equal to 5.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.Between(0, 100));
var invalid = validator.Validate(new IntValueModel { Value = 101 });
var valid = validator.Validate(new IntValueModel { Value = 100 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be between 0 and 100.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_equal()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Equal("ACTIVE"));
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_equal()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.NotEqual("BANNED"));
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_equal_with_null_reference_values()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Equal("ACTIVE"));
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
var nullValue = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(nullValue.IsValid).IsFalse();
}
[Test]
public async Task I_can_validate_not_equal_with_null_reference_values()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.NotEqual("BANNED"));
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
var nullValue = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(nullValue.IsValid).IsTrue();
}
[Test]
public async Task I_can_skip_null_values_for_reference_greater_than()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.GreaterThan("B"));
var invalid = validator.Validate(new StringValueModel { Value = "A" });
var valid = validator.Validate(new StringValueModel { Value = "C" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be greater than B.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_skip_null_values_for_reference_between()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Between("A", "C"));
var invalid = validator.Validate(new StringValueModel { Value = "D" });
var valid = validator.Validate(new StringValueModel { Value = "C" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be between A and C.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_matches_with_a_pattern()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Matches("^[A-Z]+$"));
var invalid = validator.Validate(new StringValueModel { Value = "Abc" });
var valid = validator.Validate(new StringValueModel { Value = "ABC" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_matches_with_a_regex()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Matches(new Regex(@"^\d{4}$", RegexOptions.CultureInvariant)));
var invalid = validator.Validate(new StringValueModel { Value = "12AB" });
var valid = validator.Validate(new StringValueModel { Value = "1234" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_see_it_throw_for_invalid_length_configuration()
{
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Length(5, 4)))
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_see_it_throw_for_negative_min_length_configuration()
{
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.MinLength(-1)))
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_see_it_throw_for_invalid_between_configuration()
{
await Assert.That(() => new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.Between(10, 0)))
.Throws<ArgumentOutOfRangeException>();
}
private static async Task AssertSingleProblem(Validation validation, string propertyName, string message)
{
await Assert.That(validation.Problems).Count().IsEqualTo(1);
var problem = validation.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyName).IsEqualTo(propertyName);
await Assert.That(problem.Message).IsEqualTo(message);
}
}
}

View file

@ -0,0 +1,279 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation.Tests;
internal sealed class ValidatorTests
{
[Test]
public async Task I_can_validate_a_property_rule_with_metadata()
{
var validator = CreatePersonValidator();
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.IsValid).IsFalse();
await Assert.That(result.Problems).Count().IsEqualTo(1);
var problem = result.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name));
await Assert.That(problem.Message).IsEqualTo("Name is required.");
await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED");
await Assert.That(problem.Severity).IsEqualTo(Severity.Warning);
await Assert.That(problem.AttemptedValue).IsEqualTo("");
}
}
[Test]
public async Task I_validate_all_rules_on_every_call()
{
var validator = CreatePersonValidator();
var invalid = validator.Validate(new Person { Name = "" });
var valid = validator.Validate(new Person { Name = "Louis" });
using (Assert.Multiple())
{
await Assert.That(invalid.IsValid).IsFalse();
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(valid.Problems).IsEmpty();
}
}
[Test]
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
{
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.SetValidator(CreateAddressValidator()));
var result = validator.Validate(new PersonWithAddress
{
Address = new Address { Street = "" },
});
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street");
}
[Test]
public async Task I_skip_null_nested_values_for_composed_validators()
{
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.SetValidator(CreateAddressValidator()));
var result = validator.Validate(new PersonWithAddress());
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.Problems).IsEmpty();
}
[Test]
public async Task I_can_validate_each_collection_element_and_include_the_index()
{
var validator = new CollectionValidator<Team, Member>(
team => team.Members,
rule => rule.SetValidator(CreateMemberValidator()));
var result = validator.Validate(new Team
{
Members = [new Member { Name = "Ada" }, new Member { Name = "" }],
});
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Members[1].Name");
}
[Test]
public async Task I_can_build_property_paths_for_nested_member_access()
{
var validator = new PropertyValidator<PersonWithAddress, string?>(
person => person.Address!.Street,
rule => rule.NotEmpty());
var result = validator.Validate(new PersonWithAddress { Address = new Address { Street = "" } });
await Assert.That(result.Problems).Count().IsEqualTo(1);
var problem = result.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyName).IsEqualTo("Address.Street");
await Assert.That(problem.Message).IsEqualTo("Value is required.");
}
}
[Test]
public async Task I_can_build_property_paths_for_converted_expressions()
{
var validator = new PropertyValidator<Person, object?>(
person => person.Name,
rule => rule.NotNull());
var result = validator.Validate(new Person());
await Assert.That(result.Problems).Count().IsEqualTo(1);
var problem = result.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name));
await Assert.That(problem.Message).IsEqualTo("Value is required.");
}
}
[Test]
public async Task I_see_it_throw_for_non_member_expressions()
{
await Assert.That(() => new PropertyValidator<Person, int>(
person => person.Name!.Trim().Length,
rule => rule.GreaterThan(0)))
.Throws<InvalidOperationException>();
}
[Test]
public async Task I_aggregate_all_failures_in_rule_order()
{
var validator = new OrderedFailuresValidator();
var result = validator.Validate(new StringValueModel());
await Assert.That(result.Problems).Count().IsEqualTo(3);
await Assert.That(result.Problems[0].Message).IsEqualTo("First failure.");
await Assert.That(result.Problems[1].Message).IsEqualTo("Second failure.");
await Assert.That(result.Problems[2].Message).IsEqualTo("Third failure.");
}
[Test]
public async Task I_apply_default_problem_metadata()
{
var validator = new PropertyValidator<StringValueModel, string?>(
model => model.Value,
rule => rule.NotEmpty());
var result = validator.Validate(new StringValueModel { Value = " " });
var problem = result.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.Code).IsNull();
await Assert.That(problem.Severity).IsEqualTo(Severity.Error);
await Assert.That(problem.AttemptedValue).IsEqualTo(" ");
}
}
[Test]
public async Task I_skip_null_collections_for_collection_validators()
{
var validator = new CollectionValidator<NullableTeam, Member>(
team => team.Members!,
rule => rule.SetValidator(CreateMemberValidator()));
var result = validator.Validate(new NullableTeam());
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.Problems).IsEmpty();
}
[Test]
public async Task I_skip_empty_collections_for_collection_validators()
{
var validator = new CollectionValidator<Team, Member>(
team => team.Members,
rule => rule.SetValidator(CreateMemberValidator()));
var result = validator.Validate(new Team());
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.Problems).IsEmpty();
}
[Test]
public async Task I_can_aggregate_problems_for_multiple_invalid_collection_elements()
{
var validator = new CollectionValidator<Team, Member>(
team => team.Members,
rule => rule.SetValidator(CreateMemberValidator()));
var result = validator.Validate(new Team
{
Members = [new Member { Name = "" }, new Member { Name = "Ada" }, new Member { Name = "" }],
});
await Assert.That(result.Problems).Count().IsEqualTo(2);
await Assert.That(result.Problems[0].PropertyName).IsEqualTo("Members[0].Name");
await Assert.That(result.Problems[1].PropertyName).IsEqualTo("Members[2].Name");
}
[Test]
public async Task I_can_resolve_nested_validators_from_the_context_service_provider()
{
var addressValidator = CreateAddressValidator();
var services = new ServiceCollection()
.AddSingleton(addressValidator)
.BuildServiceProvider();
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.SetValidator<PropertyValidator<Address, string?>>());
var context = new ValidationContext<PersonWithAddress>(
new PersonWithAddress { Address = new Address { Street = "" } },
services);
var result = validator.Validate(context);
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street");
}
[Test]
public async Task I_see_it_throw_for_type_mismatch()
{
var validator = CreatePersonValidator();
var context = new ValidationContext<object>(new object());
await Assert.That(() => validator.Validate(context)).Throws<InvalidOperationException>();
}
[Test]
public async Task I_see_it_throw_when_a_context_resolved_validator_cannot_be_resolved()
{
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.SetValidator<PropertyValidator<Address, string?>>());
var context = new ValidationContext<PersonWithAddress>(
new PersonWithAddress { Address = new Address { Street = "" } });
await Assert.That(() => validator.Validate(context)).Throws<InvalidOperationException>();
}
private static PropertyValidator<Person, string?> CreatePersonValidator()
{
return new PropertyValidator<Person, string?>(
person => person.Name,
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Name is required.")
.WithCode("NAME_REQUIRED")
.WithSeverity(Severity.Warning));
}
private static PropertyValidator<Address, string?> CreateAddressValidator()
{
return new PropertyValidator<Address, string?>(
address => address.Street,
rule => rule.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required."));
}
private static PropertyValidator<Member, string?> CreateMemberValidator()
{
return new PropertyValidator<Member, string?>(
member => member.Name,
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required."));
}
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class Address
{
public string? Street { get; init; }
}

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Validation.Tests;
internal sealed class CollectionValidator<T, TElement> : Validator<T>
{
public CollectionValidator(
Expression<Func<T, IEnumerable<TElement>>> expression,
Action<IPropertyRuleBuilder<T, TElement>> configure)
{
ArgumentNullException.ThrowIfNull(expression);
ArgumentNullException.ThrowIfNull(configure);
configure(RuleForEach(expression));
}
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class CollectionValueModel
{
public IEnumerable<string>? Value { get; init; }
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class IntValueModel
{
public int Value { get; init; }
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class Member
{
public string? Name { get; init; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class NullableTeam
{
public IEnumerable<Member>? Members { get; init; }
}

View file

@ -0,0 +1,17 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class OrderedFailuresValidator : Validator<StringValueModel>
{
public OrderedFailuresValidator()
{
RuleFor(model => model.Value)
.Must(_ => false, "First failure.")
.Must(_ => false, "Second failure.");
RuleFor(model => model.Value)
.Must(_ => false, "Third failure.");
}
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class Person
{
public string? Name { get; init; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Validation.Tests;
internal sealed class PropertyValidator<T, TProperty> : Validator<T>
{
public PropertyValidator(
Expression<Func<T, TProperty>> expression,
Action<IPropertyRuleBuilder<T, TProperty>> configure)
{
ArgumentNullException.ThrowIfNull(expression);
ArgumentNullException.ThrowIfNull(configure);
configure(RuleFor(expression));
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,9 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class Team
{
public IEnumerable<Member> Members { get; init; } = [];
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.Request.Validation.Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request.validation</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,48 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Represents a builder for defining a validation rule for a specific property of a type.
/// </summary>
/// <typeparam name="T">The type of the object to be validated.</typeparam>
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
public interface IPropertyRuleBuilder<T, out TProperty>
{
/// <summary>
/// Defines a validation rule that must be met for the property to be considered valid.
/// </summary>
/// <param name="predicate">The predicate function that determines if the property value is valid.</param>
/// <param name="message">The error message to be returned if the validation fails.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message);
/// <summary>
/// Sets the validator to be used for validating the property value.
/// </summary>
/// <param name="validator">The validator instance to use for validation.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator);
/// <summary>
/// Sets the validator to be used for validating the property value.
/// </summary>
/// <typeparam name="TValidator">The type of the validator to use for validation.</typeparam>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator;
/// <summary>
/// Sets the error code for the validation rule.
/// </summary>
/// <param name="code">The error code to be associated with the validation rule.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
/// <summary>
/// Sets the severity of the validation rule.
/// </summary>
/// <param name="severity">The severity level of the validation rule.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity);
}

View file

@ -0,0 +1,31 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Defines a validator for a particular type.
/// </summary>
public interface IValidator
{
/// <summary>
/// Performs validation on the provided <see cref="ValidationContext"/> and returns the result.
/// </summary>
/// <param name="context">The validation context containing the instance to validate, service provider, and additional items.</param>
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
Validation Validate(ValidationContext context);
}
/// <summary>
/// Defines a validator for a particular type.
/// </summary>
/// <typeparam name="T">The type of the instance to validate.</typeparam>
public interface IValidator<in T> : IValidator
{
/// <summary>
/// Executes the validation logic for a specified instance of type <typeparamref name="T"/> and returns the validation result.
/// </summary>
/// <param name="instance">The instance of type <typeparamref name="T"/> to validate.</param>
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
Validation Validate(T instance);
}

View file

@ -0,0 +1,35 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Represents a validation problem identified during a request validation process.
/// </summary>
public record Problem
{
/// <summary>
/// The name of the property.
/// </summary>
public required string PropertyName { get; init; }
/// <summary>
/// Custom severity level associated with the failure.
/// </summary>
public Severity Severity { get; init; } = Severity.Error;
/// <summary>
/// The error message
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets or sets the error code.
/// </summary>
public string? Code { get; init; }
/// <summary>
/// The property value that caused the failure.
/// </summary>
public object? AttemptedValue { get; init; }
}

View file

@ -0,0 +1,139 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Validation;
internal abstract record Rule
{
public Severity Severity { get; init; } = Severity.Error;
public string? Code { get; init; }
public abstract IEnumerable<Problem> Validate(ValidationContext context);
}
internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
{
PropertyName = GetPropertyPath(expression);
}
public string PropertyName { get; }
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
private static string GetPropertyPath(Expression expression)
{
var members = new Stack<string>();
var current = expression;
while (current is not null)
{
switch (current)
{
case LambdaExpression lambdaExpression:
current = lambdaExpression.Body;
break;
case MemberExpression memberExpression:
members.Push(memberExpression.Member.Name);
current = memberExpression.Expression;
break;
case UnaryExpression
{
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked
} unaryExpression:
current = unaryExpression.Operand;
break;
case ParameterExpression:
current = null;
break;
default:
throw new InvalidOperationException("Only simple member access expressions are supported.");
}
}
return string.Join(".", members);
}
public override IEnumerable<Problem> Validate(ValidationContext context)
{
if (context.Instance is T instance)
{
return Validate(instance, context);
}
if (context.Instance is null && default(T) is null)
{
return Validate((T)context.Instance!, context);
}
var actualType = context.Instance?.GetType().FullName ?? "null";
throw new InvalidOperationException(
$"Expected validation context instance of type '{typeof(T).FullName}', but got '{actualType}'.");
}
protected abstract IEnumerable<Problem> Validate(T instance, ValidationContext context);
}
internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
{
private readonly Func<T, TProperty> _accessor;
public PropertyRule(Expression<Func<T, TProperty>> expression) : base(expression)
{
_accessor = expression.Compile();
}
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
{
if (Steps.Count is 0)
{
return [];
}
var value = _accessor(instance);
return Steps.SelectMany(step => step.Validate(value, context, PropertyName, Code, Severity));
}
}
internal sealed record CollectionRule<T, TElement> : Rule<T, TElement>
{
private readonly Func<T, IEnumerable<TElement>> _accessor;
public CollectionRule(Expression<Func<T, IEnumerable<TElement>>> expression) : base(expression)
{
_accessor = expression.Compile();
}
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
{
if (Steps.Count is 0)
{
yield break;
}
if (_accessor(instance) is not { } collection)
{
yield break;
}
var index = 0;
foreach (var element in collection)
{
var propertyName = string.IsNullOrEmpty(PropertyName) ? $"[{index}]" : $"{PropertyName}[{index}]";
foreach (var step in Steps)
{
foreach (var problem in step.Validate(element, context, propertyName, Code, Severity))
{
yield return problem;
}
}
index++;
}
}
}

View file

@ -0,0 +1,319 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace Geekeey.Request.Validation;
/// <summary>
/// Provides built-in validators for common validation scenarios.
/// </summary>
public static class RuleBuilderExtensions
{
private static bool IsNull<TProperty>([NotNullWhen(false)] TProperty? value)
{
object? boxed = value;
return boxed is null;
}
/// <summary>
/// Adds a rule to ensure that the property value is not null for reference types.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated. Must be a nullable reference type.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <returns>The updated rule builder with the not-null condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> NotNull<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule)
where TProperty : class?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(static value => value is not null, "Value is required.");
}
/// <summary>
/// Adds a rule to ensure that the property value is not null for nullable value types.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The underlying non-nullable value type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <returns>The updated rule builder with the not-null condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty?> NotNull<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty?> rule)
where TProperty : struct
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(static value => value.HasValue, "Value is required.");
}
/// <summary>
/// Adds a rule to ensure that the string property value is not null, empty, or whitespace.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <returns>The updated rule builder with the not-empty condition added.</returns>
public static IPropertyRuleBuilder<T, string?> NotEmpty<T>(this IPropertyRuleBuilder<T, string?> rule)
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(static value => !string.IsNullOrWhiteSpace(value), "Value is required.");
}
/// <summary>
/// Adds a rule to ensure that the collection property value is not null and contains at least one element.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TElement">The type of the elements in the collection being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <returns>The updated rule builder with the not-empty condition added.</returns>
public static IPropertyRuleBuilder<T, IEnumerable<TElement>?> NotEmpty<T, TElement>(
this IPropertyRuleBuilder<T, IEnumerable<TElement>?> rule)
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
}
/// <summary>
/// Adds a rule to ensure that the string property value meets the specified minimum length.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="minLength">The minimum allowed number of characters.</param>
/// <returns>The updated rule builder with the minimum length condition added.</returns>
public static IPropertyRuleBuilder<T, string?> MinLength<T>(
this IPropertyRuleBuilder<T, string?> rule,
int minLength)
{
ArgumentNullException.ThrowIfNull(rule);
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
return rule.Must(value => IsNull(value) || value.Length >= minLength,
$"Value must be at least {minLength} characters long.");
}
/// <summary>
/// Adds a rule to ensure that the string property value does not exceed the specified maximum length.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="maxLength">The maximum allowed number of characters.</param>
/// <returns>The updated rule builder with the maximum length condition added.</returns>
public static IPropertyRuleBuilder<T, string?> MaxLength<T>(
this IPropertyRuleBuilder<T, string?> rule,
int maxLength)
{
ArgumentNullException.ThrowIfNull(rule);
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
return rule.Must(value => IsNull(value) || value.Length <= maxLength,
$"Value must be at most {maxLength} characters long.");
}
/// <summary>
/// Adds a rule to ensure that the string property value falls within the specified inclusive length range.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="minLength">The minimum allowed number of characters.</param>
/// <param name="maxLength">The maximum allowed number of characters.</param>
/// <returns>The updated rule builder with the length range condition added.</returns>
public static IPropertyRuleBuilder<T, string?> Length<T>(
this IPropertyRuleBuilder<T, string?> rule,
int minLength,
int maxLength)
{
ArgumentNullException.ThrowIfNull(rule);
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
if (maxLength < minLength)
{
throw new ArgumentOutOfRangeException(nameof(maxLength),
"Maximum length must be greater than or equal to minimum length.");
}
return rule.Must(value => IsNull(value) || (value.Length >= minLength && value.Length <= maxLength),
$"Value must be between {minLength} and {maxLength} characters long.");
}
/// <summary>
/// Adds a rule to ensure that the property value is greater than the specified comparison value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="comparisonValue">The value that the property must be greater than.</param>
/// <returns>The updated rule builder with the greater-than condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> GreaterThan<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty comparisonValue)
where TProperty : IComparable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0,
$"Value must be greater than {comparisonValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value is greater than or equal to the specified comparison value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="comparisonValue">The value that the property must be greater than or equal to.</param>
/// <returns>The updated rule builder with the greater-than-or-equal condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> GreaterThanOrEqualTo<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty comparisonValue)
where TProperty : IComparable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0,
$"Value must be greater than or equal to {comparisonValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value is less than the specified comparison value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="comparisonValue">The value that the property must be less than.</param>
/// <returns>The updated rule builder with the less-than condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> LessThan<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty comparisonValue)
where TProperty : IComparable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0,
$"Value must be less than {comparisonValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value is less than or equal to the specified comparison value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="comparisonValue">The value that the property must be less than or equal to.</param>
/// <returns>The updated rule builder with the less-than-or-equal condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> LessThanOrEqualTo<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty comparisonValue)
where TProperty : IComparable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0,
$"Value must be less than or equal to {comparisonValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value falls within the specified inclusive range.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="minValue">The minimum allowed value.</param>
/// <param name="maxValue">The maximum allowed value.</param>
/// <returns>The updated rule builder with the range condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> Between<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty minValue,
TProperty maxValue)
where TProperty : IComparable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
if (minValue is not null && maxValue is not null && minValue.CompareTo(maxValue) > 0)
{
throw new ArgumentOutOfRangeException(nameof(maxValue),
"Maximum value must be greater than or equal to minimum value.");
}
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0),
$"Value must be between {minValue} and {maxValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value is equal to the specified comparison value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="comparisonValue">The required value for the property.</param>
/// <returns>The updated rule builder with the equality condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> Equal<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty comparisonValue)
where TProperty : IEquatable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => EqualityComparer<TProperty>.Default.Equals(value, comparisonValue),
$"Value must be equal to {comparisonValue}.");
}
/// <summary>
/// Adds a rule to ensure that the property value is not equal to the specified disallowed value.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="disallowedValue">The value that the property must not equal.</param>
/// <returns>The updated rule builder with the inequality condition added.</returns>
public static IPropertyRuleBuilder<T, TProperty> NotEqual<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule,
TProperty disallowedValue)
where TProperty : IEquatable<TProperty>?
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => !EqualityComparer<TProperty>.Default.Equals(value, disallowedValue),
$"Value must not be equal to {disallowedValue}.");
}
/// <summary>
/// Adds a rule to ensure that the string property value matches the specified regular expression pattern.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="pattern">The regular expression pattern that the property value must match.</param>
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
public static IPropertyRuleBuilder<T, string?> Matches<T>(
this IPropertyRuleBuilder<T, string?> rule,
string pattern)
{
ArgumentNullException.ThrowIfNull(rule);
ArgumentException.ThrowIfNullOrEmpty(pattern);
return rule.Must(value => IsNull(value) || Regex.IsMatch(value, pattern),
"Value is not in the correct format.");
}
/// <summary>
/// Adds a rule to ensure that the string property value matches the specified regular expression.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <param name="rule">The rule builder to which the condition is applied.</param>
/// <param name="regex">The regular expression that the property value must match.</param>
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
public static IPropertyRuleBuilder<T, string?> Matches<T>(
this IPropertyRuleBuilder<T, string?> rule,
Regex regex)
{
ArgumentNullException.ThrowIfNull(rule);
ArgumentNullException.ThrowIfNull(regex);
return rule.Must(value => IsNull(value) || regex.IsMatch(value),
"Value is not in the correct format.");
}
}

View file

@ -0,0 +1,79 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
internal interface IRuleStep<in TValue>
{
IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity);
}
internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
{
private readonly Func<TValue, bool> _predicate;
private readonly string _message;
public PredicateRuleStep(Func<TValue, bool> predicate, string message)
{
_predicate = predicate;
_message = message;
}
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
{
if (_predicate(value))
{
yield break;
}
yield return new Problem
{
PropertyName = propertyPath,
Message = _message,
Code = code,
Severity = severity,
AttemptedValue = value,
};
}
}
internal sealed record ValidatorRuleStep<TValue> : IRuleStep<TValue>
{
private readonly Func<ValidationContext, IValidator> _resolver;
public ValidatorRuleStep(Func<ValidationContext, IValidator> resolver)
{
_resolver = resolver;
}
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
{
if (value is null)
{
yield break;
}
var clone = new ValidationContext<object?>(value, context.ServiceProvider, context.Items);
var validation = _resolver(context).Validate(clone);
foreach (var problem in validation.Problems)
{
yield return problem with { PropertyName = AppendPropertyPath(propertyPath, problem.PropertyName) };
}
}
private static string AppendPropertyPath(string prefix, string suffix)
{
if (string.IsNullOrEmpty(prefix))
{
return suffix;
}
if (string.IsNullOrEmpty(suffix))
{
return prefix;
}
return $"{prefix}.{suffix}";
}
}

View file

@ -0,0 +1,25 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Specifies the severity of a rule.
/// </summary>
public enum Severity
{
/// <summary>
/// Error
/// </summary>
Error,
/// <summary>
/// Warning
/// </summary>
Warning,
/// <summary>
/// Info
/// </summary>
Info,
}

View file

@ -0,0 +1,32 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Represents the result of executing one or more validations against a given object or request.
/// </summary>
public sealed class Validation
{
/// <summary>
/// Represents the result of executing one or more validations against a given object or request.
/// </summary>
/// <remarks>
/// This class contains the list of validation problems identified during the validation process.
/// A validation is considered successful if no problems are found.
/// </remarks>
public Validation(IEnumerable<Problem> problems)
{
Problems = [.. problems];
}
/// <summary>
/// Whether the validation was successful.
/// </summary>
public bool IsValid => Problems.Count is 0;
/// <summary>
/// The problems that were found during validation.
/// </summary>
public IReadOnlyList<Problem> Problems { get; }
}

View file

@ -0,0 +1,64 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.ObjectModel;
namespace Geekeey.Request.Validation;
/// <summary>
/// Represents the context in which validation occurs, providing information about the
/// instance being validated, service provider, and additional items for use in validation.
/// </summary>
public abstract class ValidationContext
{
/// <summary>
/// Creates a new validation context.
/// </summary>
/// <param name="instance">The object currently being validated.</param>
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
/// <param name="items">Per-call state shared across nested validation operations.</param>
protected ValidationContext(object? instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
{
Instance = instance;
ServiceProvider = serviceProvider;
Items = items ?? ReadOnlyDictionary<object, object?>.Empty;
}
/// <summary>
/// The object currently being validated.
/// </summary>
public object? Instance { get; }
/// <summary>
/// The service provider available for nested validator resolution.
/// </summary>
public IServiceProvider? ServiceProvider { get; }
/// <summary>
/// Per-call state shared across nested validation operations.
/// </summary>
public IReadOnlyDictionary<object, object?> Items { get; }
}
/// <summary>
/// Represents the context in which validation occurs, providing information about the
/// instance being validated, service provider, and additional items for use in validation.
/// </summary>
public sealed class ValidationContext<T> : ValidationContext
{
/// <summary>
/// Creates a new validation context.
/// </summary>
/// <param name="instance">The object currently being validated.</param>
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
/// <param name="items">Per-call state shared across nested validation operations.</param>
public ValidationContext(T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
: base(instance, serviceProvider, items)
{
}
/// <summary>
/// The object currently being validated.
/// </summary>
public new T? Instance => base.Instance is T value ? value : default;
}

View file

@ -0,0 +1,143 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Linq.Expressions;
namespace Geekeey.Request.Validation;
/// <summary>
/// Represents the base class for defining validation logic for a specific type.
/// </summary>
/// <typeparam name="T">The type of the object to be validated.</typeparam>
public abstract class Validator<T> : IValidator<T>
{
private readonly List<Rule> _rules = [];
/// <summary>
/// Defines a validation rule for a specific property of the type being validated.
/// </summary>
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
/// <param name="expression">An expression representing the property to validate.</param>
/// <returns>An object that allows further configuration of the validation rule.</returns>
public IPropertyRuleBuilder<T, TProperty> RuleFor<TProperty>(
Expression<Func<T, TProperty>> expression)
{
ArgumentNullException.ThrowIfNull(expression);
_rules.Add(new PropertyRule<T, TProperty>(expression));
return new PropertyRuleBuilder<TProperty>(_rules, _rules.Count - 1);
}
/// <summary>
/// Defines a validation rule for each element in a collection property of the type being validated.
/// </summary>
/// <typeparam name="TElement">The type of the elements in the collection to validate.</typeparam>
/// <param name="expression">An expression representing the collection property to validate.</param>
/// <returns>An object that allows further configuration of the validation rule.</returns>
public IPropertyRuleBuilder<T, TElement> RuleForEach<TElement>(
Expression<Func<T, IEnumerable<TElement>>> expression)
{
ArgumentNullException.ThrowIfNull(expression);
_rules.Add(new CollectionRule<T, TElement>(expression));
return new PropertyRuleBuilder<TElement>(_rules, _rules.Count - 1);
}
/// <inheritdoc/>
public Validation Validate(T instance)
{
return Validate(new ValidationContext<T>(instance));
}
/// <inheritdoc/>
public Validation Validate(ValidationContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new Validation(_rules.SelectMany(rule => rule.Validate(context)));
}
private static IValidator ResolveValidator<TValidator>(ValidationContext context)
where TValidator : IValidator
{
if (context.ServiceProvider is null)
{
throw new InvalidOperationException(
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' because the validation context has no service provider.");
}
if (context.ServiceProvider.GetService(typeof(TValidator)) is not TValidator validator)
{
throw new InvalidOperationException(
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' from the validation context service provider.");
}
return validator;
}
private sealed class PropertyRuleBuilder<TProperty>
: IPropertyRuleBuilder<T, TProperty>
{
private readonly List<Rule> _rules;
private readonly int _index;
public PropertyRuleBuilder(List<Rule> rules, int index)
{
_rules = rules;
_index = index;
}
private Rule<T, TProperty> CurrentRule
{
get => (Rule<T, TProperty>)_rules[_index];
set => _rules[_index] = value;
}
public IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message)
{
ArgumentNullException.ThrowIfNull(predicate);
ArgumentException.ThrowIfNullOrEmpty(message);
var step = new PredicateRuleStep<TProperty>(predicate, message);
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
return this;
}
public IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator)
{
ArgumentNullException.ThrowIfNull(validator);
var step = new ValidatorRuleStep<TProperty>(_ => validator);
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
return this;
}
public IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator
{
var step = new ValidatorRuleStep<TProperty>(ResolveValidator<TValidator>);
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithCode(string code)
{
ArgumentException.ThrowIfNullOrEmpty(code);
CurrentRule = CurrentRule with { Code = code };
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
{
CurrentRule = CurrentRule with { Severity = severity };
return this;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,85 @@
Request.Validation is a lightweight validation library for C#, built around composable validators, fluent rules, and
structured validation results.
## Features
- **Composable validators:** Build validators by inheriting from `Validator<T>` and defining rules with `RuleFor` and
`RuleForEach`.
- **Built-in and custom rules:** Use helpers like `NotEmpty`, `Length`, `Between`, and `Matches`, or define custom
predicates with `Must`.
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
code, and attempted value.
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution.
## Getting Started
### Install the NuGet package:
```shell
dotnet add package Geekeey.Request.Validation
```
You may need to add our NuGet feed to your nuget.config this can be done by running the following command:
```shell
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
```
### Usage
```csharp
using Geekeey.Request.Validation;
public sealed record Address(string? Street);
public sealed record CreateUserRequest(
string? Name,
int Age,
Address? Address,
IReadOnlyList<string> Tags);
public sealed class AddressValidator : Validator<Address>
{
public AddressValidator()
{
RuleFor(address => address.Street)
.NotEmpty();
}
}
public sealed class CreateUserRequestValidator : Validator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(request => request.Name)
.NotEmpty()
.Length(2, 100)
.WithCode("NAME_INVALID");
RuleFor(request => request.Age)
.Between(18, 120);
RuleFor(request => request.Address)
.SetValidator(new AddressValidator());
RuleForEach(request => request.Tags)
.NotEmpty()
.WithSeverity(Severity.Warning);
}
}
var validator = new CreateUserRequestValidator();
var validation = validator.Validate(new CreateUserRequest(
Name: "",
Age: 16,
Address: new Address(""),
Tags: ["", "admin"]));
foreach (var problem in validation.Problems)
{
Console.WriteLine($"{problem.PropertyName}: {problem.Message}");
}
```
The resulting `Problem` entries include full property paths like `Address.Street` and `Tags[0]`, making it easy to
surface validation errors back to callers or APIs.