5

I have WebAPI (.NET Core) and use FluentValidator to validate model, including updating. I use PATCH verb and have the following method:

    public IActionResult Update(int id, [FromBody] JsonPatchDocument<TollUpdateAPI> jsonPatchDocument)
    {

also, I have a validator class:

public class TollUpdateFluentValidator : AbstractValidator<TollUpdateAPI>
{
    public TollUpdateFluentValidator ()
    {
        RuleFor(d => d.Date)
            .NotNull().WithMessage("Date is required");

        RuleFor(d => d.DriverId)
            .GreaterThan(0).WithMessage("Invalid DriverId");

        RuleFor(d => d.Amount)
            .NotNull().WithMessage("Amount is required");

        RuleFor(d => d.Amount)
            .GreaterThanOrEqualTo(0).WithMessage("Invalid Amount");
    }
}

and map this validator in Startup class:

        services.AddTransient<IValidator<TollUpdateAPI>, TollUpdateFluentValidator>();

but it does not work. How to write valid FluentValidator for my task?

Oleg Sh
  • 8,496
  • 17
  • 89
  • 159

4 Answers4

1

You will need to trigger the validation manually. Your action method will be somthing like this:

public IActionResult Update(int id, [FromBody] JsonPatchDocument<TollUpdateAPI> jsonPatchDocument)
{
    // Load your db entity
    var myDbEntity = myService.LoadEntityFromDb(id);

    // Copy/Map data to the entity to patch using AutoMapper for example
    var entityToPatch = myMapper.Map<TollUpdateAPI>(myDbEntity);

    // Apply the patch to the entity to patch
    jsonPatchDocument.ApplyTo(entityToPatch);

    // Trigger validation manually
    var validationResult = new TollUpdateFluentValidator().Validate(entityToPatch);
    if (!validationResult.IsValid)
    {
        // Add validation errors to ModelState
        foreach (var error in validationResult.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        // Patch failed, return 422 result
        return UnprocessableEntity(ModelState);
    }

    // Map the patch to the dbEntity
    myMapper.Map(entityToPatch, myDbEntity);
    myService.SaveChangesToDb();

    // So far so good, patch done
    return NoContent();
}
Stacked
  • 6,892
  • 7
  • 57
  • 73
1

You can utilize a custom rule builder for this. It might not be the most elegant way of handling it but at least the validation logic is where you expect it to be.

Say you have the following request model:

public class CarRequestModel
{
    public string Make { get; set; }
    public string Model { get; set; }
    public decimal EngineDisplacement { get; set; }
}

Your Validator class can inherit from the AbstractValidator of JsonPatchDocument instead of the concrete request model type.

The fluent validator, on the other hand, provides us with decent extension points such as the Custom rule.

Combining these two ideas you can create something like this:

public class Validator : AbstractValidator<JsonPatchDocument<CarRequestModel>>
{
    public Validator()
    {
        RuleForEach(x => x.Operations)
           .Custom(HandleInternalPropertyValidation);
    }

    private void HandleInternalPropertyValidation(JsonPatchOperation property, CustomContext context)
    {
        void AddFailureForPropertyIf<T>(
            Expression<Func<T, object>> propertySelector,
            JsonPatchOperationType operation,
            Func<JsonPatchOperation, bool> predicate, string errorMessage)
        {
            var propertyName = (propertySelector.Body as MemberExpression)?.Member.Name;
            if (propertyName is null)
                throw new ArgumentException("Property selector must be of type MemberExpression");

            if (!property.Path.ToLowerInvariant().Contains(propertyName.ToLowerInvariant()) ||
                property.Operation != operation) return;

            if (predicate(property)) context.AddFailure(propertyName, errorMessage);
        }

        AddFailureForPropertyIf<CarRequestModel>(x => x.Make, JsonPatchOperationType.remove,
            x => true, "Car Make cannot be removed.");
        AddFailureForPropertyIf<CarRequestModel>(x => x.EngineDisplacement, JsonPatchOperationType.replace,
            x => (decimal) x.Value < 12m, "Engine displacement must be less than 12l.");
    }
}

In some cases, it might be tedious to write down all the actions that are not allowed from the domain perspective but are defined in the JsonPatch RFC.

This problem could be eased by defining none but rules which would define the set of operations that are valid from the perspective of your domain.

Community
  • 1
  • 1
Pavisa
  • 102
  • 7
0

Realization bellow allow use IValidator<Model> inside IValidator<JsonPatchDocument<Model>>, but you need create model with valid properties values.

public class ModelValidator : AbstractValidator<JsonPatchDocument<Model>>
{
    public override ValidationResult Validate(ValidationContext<JsonPatchDocument<Model>> context)
    {
        return _validator.Validate(GetRequestToValidate(context));
    }

    public override Task<ValidationResult> ValidateAsync(ValidationContext<JsonPatchDocument<Model>> context, CancellationToken cancellation = default)
    {
        return _validator.ValidateAsync(GetRequestToValidate(context), cancellation);
    }

    private static Model GetRequestToValidate(ValidationContext<JsonPatchDocument<Model>> context)
    {
        var validModel = new Model()
                           {
                               Name = nameof(Model.Name),
                               Url = nameof(Model.Url)
                           };

        context.InstanceToValidate.ApplyTo(validModel);
        return validModel;
    }

    private class Validator : AbstractValidator<Model>
    {
        /// <inheritdoc />
        public Validator()
        {
            RuleFor(r => r.Name).NotEmpty();
            RuleFor(r => r.Url).NotEmpty();
        }
    }

    private static readonly Validator _validator = new();
}
0

You may try the below generic validator - it validates only updated properties:

    public class JsonPatchDocumentValidator<T> : AbstractValidator<JsonPatchDocument<T>> where T: class, new()
    {
        private readonly IValidator<T> _validator;

        public JsonPatchDocumentValidator(IValidator<T> validator)
        {
            _validator = validator;
        }

        private static string NormalizePropertyName(string propertyName)
        {
            if (propertyName[0] == '/')
            {
                propertyName = propertyName.Substring(1);
            }
            return char.ToUpper(propertyName[0]) + propertyName.Substring(1);
        }
        // apply path to the model
        private static T ApplyPath(JsonPatchDocument<T> patchDocument)
        {
            var model = new T();
            patchDocument.ApplyTo(model);
            return model;
        }
        // returns only updated properties
        private static string[] CollectUpdatedProperties(JsonPatchDocument<T> patchDocument)
            => patchDocument.Operations.Select(t => NormalizePropertyName(t.path)).Distinct().ToArray();
        public override ValidationResult Validate(ValidationContext<JsonPatchDocument<T>> context)
        {
            return _validator.Validate(ApplyPath(context.InstanceToValidate),
                o => o.IncludeProperties(CollectUpdatedProperties(context.InstanceToValidate)));
        }

        public override async Task<ValidationResult> ValidateAsync(ValidationContext<JsonPatchDocument<T>> context, CancellationToken cancellation = new CancellationToken())
        {
            return await _validator.ValidateAsync(ApplyPath(context.InstanceToValidate),
                o => o.IncludeProperties(CollectUpdatedProperties(context.InstanceToValidate)), cancellation);
        }
    }

it has to be registered manually:

builder.Services.AddScoped<IValidator<JsonPatchDocument<TollUpdateAPI>>, JsonPatchDocumentValidator<TollUpdateAPI>>();