feat: add property path transformation

This resolves #3 to allow custom property paths to be defined.
This commit is contained in:
Louis Seubert 2026-05-22 21:09:22 +02:00
commit a7ecf08efb
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
7 changed files with 167 additions and 14 deletions

View file

@ -39,6 +39,13 @@ public interface IPropertyRuleBuilder<T, out TProperty>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
/// <summary>
/// Transforms the property path reported by the validation rule.
/// </summary>
/// <param name="transform">The function used to transform the rule property path.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform);
/// <summary>
/// Sets the severity of the validation rule.
/// </summary>

View file

@ -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<Problem> Validate(ValidationContext context);
}
internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
{
PropertyPath = GetPropertyPath(expression);
}
public PropertyPath PropertyPath { get; }
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
protected PropertyPath GetPropertyPath()
{
return PropertyPathTransform(PropertyPath);
}
private static PropertyPath GetPropertyPath(Expression expression)
{
@ -55,6 +47,26 @@ internal abstract record Rule<T, TProperty> : Rule
return string.Join('.', members);
}
public PropertyPath PropertyPath { get; }
public Severity Severity { get; init; } = Severity.Error;
public string? Code { get; init; }
public Func<PropertyPath, PropertyPath> PropertyPathTransform { get; init; } = static path => path;
public abstract IEnumerable<Problem> Validate(ValidationContext context);
}
internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
: base(expression)
{
}
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
public override IEnumerable<Problem> Validate(ValidationContext context)
{
if (context.Instance is T instance)
@ -92,7 +104,8 @@ internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
}
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<T, TElement> : Rule<T, TElement>
var index = 0;
foreach (var element in collection)
{
var propertyPath = PropertyPath + index;
var propertyPath = GetPropertyPath() + index;
foreach (var step in Steps)
{

View file

@ -77,6 +77,21 @@ public static class RuleBuilderExtensions
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
}
/// <summary>
/// Removes the current rule property path from emitted validation problems.
/// </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 property path transform is applied.</param>
/// <returns>The updated rule builder with the property path removed.</returns>
public static IPropertyRuleBuilder<T, TProperty> WithoutPropertyPath<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule)
{
ArgumentNullException.ThrowIfNull(rule);
return rule.WithPropertyPath(static _ => string.Empty);
}
/// <summary>
/// Adds a rule to ensure that the string property value meets the specified minimum length.
/// </summary>

View file

@ -133,6 +133,15 @@ public abstract class Validator<T> : IValidator<T>
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform)
{
ArgumentNullException.ThrowIfNull(transform);
CurrentRule = CurrentRule with { PropertyPathTransform = transform };
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
{
CurrentRule = CurrentRule with { Severity = severity };

View file

@ -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<CreateUserRequest>
}
}
public sealed record LoginInput(string? Username);
public sealed record LoginRequest(LoginInput Input);
public sealed class LoginInputValidator : Validator<LoginInput>
{
public LoginInputValidator()
{
RuleFor(input => input.Username)
.NotEmpty();
}
}
public sealed class LoginRequestValidator : Validator<LoginRequest>
{
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.