feat: add property path transformation
This resolves #3 to allow custom property paths to be defined.
This commit is contained in:
parent
ef734ad02e
commit
a7ecf08efb
7 changed files with 167 additions and 14 deletions
|
|
@ -46,6 +46,17 @@ internal sealed class RuleBuilderExtensionsTests
|
||||||
await Assert.That(valid.IsValid).IsTrue();
|
await Assert.That(valid.IsValid).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task I_can_remove_the_property_path()
|
||||||
|
{
|
||||||
|
var validator = new PropertyValidator<StringValueModel, string?>(model =>
|
||||||
|
model.Value, rule => rule.WithoutPropertyPath().NotEmpty());
|
||||||
|
|
||||||
|
var result = validator.Validate(new StringValueModel { Value = " " });
|
||||||
|
|
||||||
|
await AssertSingleProblem(result, string.Empty, "Value is required.");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task I_can_validate_not_empty_for_collections()
|
public async Task I_can_validate_not_empty_for_collections()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,22 @@ internal sealed class ValidatorTests
|
||||||
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street");
|
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<PersonWithAddress, Address?>(
|
||||||
|
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]
|
[Test]
|
||||||
public async Task I_skip_null_nested_values_for_composed_validators()
|
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");
|
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, Member>(
|
||||||
|
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]
|
[Test]
|
||||||
public async Task I_can_build_property_paths_for_nested_member_access()
|
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, string?>(
|
||||||
|
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<PersonWithAddress, Address?>(
|
||||||
|
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]
|
[Test]
|
||||||
public async Task I_see_it_throw_for_non_member_expressions()
|
public async Task I_see_it_throw_for_non_member_expressions()
|
||||||
{
|
{
|
||||||
|
|
@ -276,4 +338,12 @@ internal sealed class ValidatorTests
|
||||||
member => member.Name,
|
member => member.Name,
|
||||||
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required."));
|
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PropertyValidator<Address, string?> CreateRenamedAddressValidator()
|
||||||
|
{
|
||||||
|
return new PropertyValidator<Address, string?>(
|
||||||
|
address => address.Street,
|
||||||
|
rule => rule.WithPropertyPath(static _ => "Road")
|
||||||
|
.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ public interface IPropertyRuleBuilder<T, out TProperty>
|
||||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||||
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
|
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>
|
/// <summary>
|
||||||
/// Sets the severity of the validation rule.
|
/// Sets the severity of the validation rule.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,16 @@ using System.Linq.Expressions;
|
||||||
namespace Geekeey.Request.Validation;
|
namespace Geekeey.Request.Validation;
|
||||||
|
|
||||||
internal abstract record Rule
|
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)
|
protected Rule(Expression expression)
|
||||||
{
|
{
|
||||||
PropertyPath = GetPropertyPath(expression);
|
PropertyPath = GetPropertyPath(expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PropertyPath PropertyPath { get; }
|
protected PropertyPath GetPropertyPath()
|
||||||
|
{
|
||||||
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
|
return PropertyPathTransform(PropertyPath);
|
||||||
|
}
|
||||||
|
|
||||||
private static PropertyPath GetPropertyPath(Expression expression)
|
private static PropertyPath GetPropertyPath(Expression expression)
|
||||||
{
|
{
|
||||||
|
|
@ -55,6 +47,26 @@ internal abstract record Rule<T, TProperty> : Rule
|
||||||
return string.Join('.', members);
|
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)
|
public override IEnumerable<Problem> Validate(ValidationContext context)
|
||||||
{
|
{
|
||||||
if (context.Instance is T instance)
|
if (context.Instance is T instance)
|
||||||
|
|
@ -92,7 +104,8 @@ internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
|
||||||
}
|
}
|
||||||
|
|
||||||
var value = _accessor(instance);
|
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;
|
var index = 0;
|
||||||
foreach (var element in collection)
|
foreach (var element in collection)
|
||||||
{
|
{
|
||||||
var propertyPath = PropertyPath + index;
|
var propertyPath = GetPropertyPath() + index;
|
||||||
|
|
||||||
foreach (var step in Steps)
|
foreach (var step in Steps)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,21 @@ public static class RuleBuilderExtensions
|
||||||
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
|
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>
|
/// <summary>
|
||||||
/// Adds a rule to ensure that the string property value meets the specified minimum length.
|
/// Adds a rule to ensure that the string property value meets the specified minimum length.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@ public abstract class Validator<T> : IValidator<T>
|
||||||
return this;
|
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)
|
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
|
||||||
{
|
{
|
||||||
CurrentRule = CurrentRule with { Severity = severity };
|
CurrentRule = CurrentRule with { Severity = severity };
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
|
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
|
||||||
code, and attempted value.
|
code, and attempted value.
|
||||||
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution.
|
- **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
|
## 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 validator = new CreateUserRequestValidator();
|
||||||
var validation = validator.Validate(new CreateUserRequest(
|
var validation = validator.Validate(new CreateUserRequest(
|
||||||
Name: "",
|
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
|
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.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue