From a7ecf08efbcf9be155ed2ffbe2fae478ce628528 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 22 May 2026 21:09:22 +0200 Subject: [PATCH] feat: add property path transformation This resolves #3 to allow custom property paths to be defined. --- .../RuleBuilderExtensionsTests.cs | 11 +++ .../ValidatorTests.cs | 70 +++++++++++++++++++ .../IPropertyRuleBuilder.cs | 7 ++ src/request.validation/Rule.cs | 41 +++++++---- .../RuleBuilderExtensions.cs | 15 ++++ src/request.validation/Validator.cs | 9 +++ src/request.validation/package-readme.md | 28 ++++++++ 7 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs index 23f84fe..23395bb 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -46,6 +46,17 @@ internal sealed class RuleBuilderExtensionsTests await Assert.That(valid.IsValid).IsTrue(); } + [Test] + public async Task I_can_remove_the_property_path() + { + var validator = new PropertyValidator(model => + model.Value, rule => rule.WithoutPropertyPath().NotEmpty()); + + var result = validator.Validate(new StringValueModel { Value = " " }); + + await AssertSingleProblem(result, string.Empty, "Value is required."); + } + [Test] public async Task I_can_validate_not_empty_for_collections() { diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index 891ed83..a40151d 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -61,6 +61,22 @@ internal sealed class ValidatorTests await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street"); } + [Test] + public async Task I_can_remove_the_parent_property_path_for_nested_validators() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.WithoutPropertyPath().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().PropertyPath).IsEqualTo("Street"); + } + [Test] public async Task I_skip_null_nested_values_for_composed_validators() { @@ -90,6 +106,22 @@ internal sealed class ValidatorTests await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Members[1].Name"); } + [Test] + public async Task I_can_transform_the_collection_property_path_before_appending_indices() + { + var validator = new CollectionValidator( + team => team.Members, + rule => rule.WithPropertyPath(static _ => "People").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().PropertyPath).IsEqualTo("People[1].Name"); + } + [Test] public async Task I_can_build_property_paths_for_nested_member_access() { @@ -129,6 +161,36 @@ internal sealed class ValidatorTests } } + [Test] + public async Task I_can_transform_a_property_rule_path() + { + var validator = new PropertyValidator( + person => person.Name, + rule => rule.WithPropertyPath(static _ => "Username").NotEmpty()); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Username"); + } + + [Test] + public async Task I_can_compose_parent_and_child_property_path_transforms() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.WithPropertyPath(static _ => "Location") + .SetValidator(CreateRenamedAddressValidator())); + + var result = validator.Validate(new PersonWithAddress + { + Address = new Address { Street = "" }, + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Location.Road"); + } + [Test] public async Task I_see_it_throw_for_non_member_expressions() { @@ -276,4 +338,12 @@ internal sealed class ValidatorTests member => member.Name, rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required.")); } + + private static PropertyValidator CreateRenamedAddressValidator() + { + return new PropertyValidator( + address => address.Street, + rule => rule.WithPropertyPath(static _ => "Road") + .Must(street => !string.IsNullOrWhiteSpace(street), "Street is required.")); + } } diff --git a/src/request.validation/IPropertyRuleBuilder.cs b/src/request.validation/IPropertyRuleBuilder.cs index babd0de..d9fa539 100644 --- a/src/request.validation/IPropertyRuleBuilder.cs +++ b/src/request.validation/IPropertyRuleBuilder.cs @@ -39,6 +39,13 @@ public interface IPropertyRuleBuilder /// The current rule builder instance for method chaining. IPropertyRuleBuilder WithCode(string code); + /// + /// Transforms the property path reported by the validation rule. + /// + /// The function used to transform the rule property path. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithPropertyPath(Func transform); + /// /// Sets the severity of the validation rule. /// diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs index 220b085..8b54d64 100644 --- a/src/request.validation/Rule.cs +++ b/src/request.validation/Rule.cs @@ -6,24 +6,16 @@ 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) { PropertyPath = GetPropertyPath(expression); } - public PropertyPath PropertyPath { get; } - - public IReadOnlyList> Steps { get; init; } = []; + protected PropertyPath GetPropertyPath() + { + return PropertyPathTransform(PropertyPath); + } private static PropertyPath GetPropertyPath(Expression expression) { @@ -55,6 +47,26 @@ internal abstract record Rule : Rule return string.Join('.', members); } + public PropertyPath PropertyPath { get; } + + public Severity Severity { get; init; } = Severity.Error; + + public string? Code { get; init; } + + public Func PropertyPathTransform { get; init; } = static path => path; + + public abstract IEnumerable Validate(ValidationContext context); +} + +internal abstract record Rule : Rule +{ + protected Rule(Expression expression) + : base(expression) + { + } + + public IReadOnlyList> Steps { get; init; } = []; + public override IEnumerable Validate(ValidationContext context) { if (context.Instance is T instance) @@ -92,7 +104,8 @@ internal sealed record PropertyRule : Rule } var value = _accessor(instance); - return Steps.SelectMany(step => step.Validate(value, context, PropertyPath, Code, Severity)); + var propertyPath = GetPropertyPath(); + return Steps.SelectMany(step => step.Validate(value, context, propertyPath, Code, Severity)); } } @@ -120,7 +133,7 @@ internal sealed record CollectionRule : Rule var index = 0; foreach (var element in collection) { - var propertyPath = PropertyPath + index; + var propertyPath = GetPropertyPath() + index; foreach (var step in Steps) { diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs index f508654..528b387 100644 --- a/src/request.validation/RuleBuilderExtensions.cs +++ b/src/request.validation/RuleBuilderExtensions.cs @@ -77,6 +77,21 @@ public static class RuleBuilderExtensions return rule.Must(static value => value is not null && value.Any(), "Value is required."); } + /// + /// Removes the current rule property path from emitted validation problems. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the property path transform is applied. + /// The updated rule builder with the property path removed. + public static IPropertyRuleBuilder WithoutPropertyPath( + this IPropertyRuleBuilder rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.WithPropertyPath(static _ => string.Empty); + } + /// /// Adds a rule to ensure that the string property value meets the specified minimum length. /// diff --git a/src/request.validation/Validator.cs b/src/request.validation/Validator.cs index 49ef812..d476de7 100644 --- a/src/request.validation/Validator.cs +++ b/src/request.validation/Validator.cs @@ -133,6 +133,15 @@ public abstract class Validator : IValidator return this; } + public IPropertyRuleBuilder WithPropertyPath(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + CurrentRule = CurrentRule with { PropertyPathTransform = transform }; + + return this; + } + public IPropertyRuleBuilder WithSeverity(Severity severity) { CurrentRule = CurrentRule with { Severity = severity }; diff --git a/src/request.validation/package-readme.md b/src/request.validation/package-readme.md index b58caa2..c259d34 100644 --- a/src/request.validation/package-readme.md +++ b/src/request.validation/package-readme.md @@ -7,6 +7,8 @@ - **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. +- **Configurable paths:** Rewrite or remove rule property paths when the reported path should differ from the CLR + member path. ## Getting Started @@ -65,6 +67,29 @@ public sealed class CreateUserRequestValidator : Validator } } +public sealed record LoginInput(string? Username); + +public sealed record LoginRequest(LoginInput Input); + +public sealed class LoginInputValidator : Validator +{ + public LoginInputValidator() + { + RuleFor(input => input.Username) + .NotEmpty(); + } +} + +public sealed class LoginRequestValidator : Validator +{ + public LoginRequestValidator() + { + RuleFor(request => request.Input) + .WithoutPropertyPath() + .SetValidator(new LoginInputValidator()); + } +} + var validator = new CreateUserRequestValidator(); var validation = validator.Validate(new CreateUserRequest( Name: "", @@ -80,3 +105,6 @@ foreach (var problem in validation.Problems) 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. + +If the validation path needs to differ from the CLR property path, use `WithPropertyPath(...)` for a custom transform +or `WithoutPropertyPath()` to drop the current rule path entirely before nested paths are appended.