KevinDockx / JsonPatch

JSON Patch (JsonPatchDocument) RFC 6902 implementation for .NET
MIT License
173 stars 29 forks source link

enhancement: ApplyTo different type and overload for Protected properties #90

Closed ahedreville closed 5 years ago

ahedreville commented 5 years ago

Hi Kevin, let me introduce my case. I have a DepartmentForUpdateDto that we could think on it as a "contract" for the API endpoint of my controller.

    public sealed class DepartmentForUpdateDto
    {
        [Required]
        [MaxLength(50)]
        public string Name { get; set; }

        [MaxLength(50)]
        public string GroupName { get; set; }

I am using it like this..

        [HttpPatch("{id}", Name = nameof(PatchDepartment))]
        public async Task<IActionResult> PatchDepartment(int id, [FromBody] JsonPatchDocument<DepartmentForUpdateDto> patch) // The patch operation is on the dto and not directly the entity to avoid exposing entity implementation details.
        {
            if (!ModelState.IsValid) return BadRequest(ModelState);

            patch.ApplyTo(new DepartmentForUpdateDto(), ModelState);                                                       // Patch a temporal DepartmentForUpdateDto dto "contract", passing a model state to catch errors like trying to update properties that doesn't exist.

            if (!ModelState.IsValid) return BadRequest(ModelState);                                                          

Ok. but my intention is to get "finally" applied the operations to the entity Department which is different type

Moreover, since has protected properties can not be updated.

public class Department { public Department(string name, groupName) {

    public string Name { get;  protected set;}
    public string GroupName { get; protected set;}
{
    "department": [
        "The property at path 'name' could not be updated.",
        "The property at path 'groupName' could not be updated."
    ]
}

For sure this are not issues, but having those additional overload functionalities of: 1) able to apply the operations to a different type, and 2) able to set value on protected properties will be very helpful

BR PS: The path I am following to workaround those limitations is

a) get the operations as dictionary

patch.Operations.Where(o => o.OperationType == OperationType.Replace).ToDictionary(r => r.path, r => r.value)

b) build and expando object with the operations

dynamic data = command.Operations.Aggregate(new ExpandoObject() as IDictionary<string, object>, (a, p) => { a.Add(p.Key.Replace("/", ""), p.Value); return a; });

c) Use automaper to map the properties to the entity

KevinDockx commented 5 years ago

Hi,

could I suggest another approach? When patching a resource I tend to:

1) get the matching entity ("originalEntity") from your repository. This can be used to check if it exists in the first place. If it doesn't, return 404. 2) map that entity to a DTO ("mappedDto") with AutoMapper.
3) apply the patch document to that mappedDto instance. 4) validate, and if validation checks out, map the patched DTO back into the entity you got in step 1 (with AutoMapper, eg: _mapper.Map(mappedDto, originalEntity).

That should solve your issue if I'm not mistaken.

KR, Kevin

ahedreville commented 5 years ago

Hi Kevin, thanks a lot for your reply, deeply appreciated. Indeed your proposed path is the right path to follow. I follow it to simplify my controllers, and it works like a charm.

I had build a custom validation attribute to validate the incoming patch document before the action controller get started.

[AttributeUsage(AttributeTargets.Parameter)]
public sealed class ValidJsonPatchBindingAttribute : ValidationAttribute
{
    private const string DefaultErrorMessage = "{0}";

    public ValidJsonPatchBindingAttribute()
    : base(DefaultErrorMessage)
    {
    }

    public override string FormatErrorMessage(string errorMessage) => string.Format(CultureInfo.InvariantCulture, ErrorMessageString, errorMessage);

    protected override ValidationResult IsValid(object patch, ValidationContext context)
    {
        if (patch == null) return ValidationResult.Success;

        string logErrorMessage = null;

        Action<JsonPatchError> logErrorAction = e => { logErrorMessage = e.ErrorMessage; };

        var dtoType = patch.GetType().GetGenericArguments().First();                                            // Get the type behind the generic JsonPatchDocument<T>.

        var dto = Activator.CreateInstance(dtoType);                                                            // Create a new object of the same T underlying type.

        var applyTo = patch.GetType().GetMethod("ApplyTo", new[] { dtoType, logErrorAction.GetType() });        // Get the ApplyTo(TModel objectToApplyTo, Action<JsonPatchError> logErrorAction) method from JsonPatchDocument<T>

        applyTo.Invoke(patch, new[] { dto, logErrorAction });                                                   // Patch a temporal dto "contract" to catch errors like trying to update properties that doesn't exist.

        if (logErrorMessage != null) return new ValidationResult(FormatErrorMessage(logErrorMessage));          // If error found on the pach document itself return.

        var validator = context.GetService<IModelValidator>();                                                  // Get the model validator to validate the patched mmodel.

        return validator.ValidateModel(out var results, dto)
            ?
            ValidationResult.Success :
            new ValidationResult(FormatErrorMessage(results.First().ErrorMessage));                             // If error found on the model return it.
    }
}

Using on the controller action as below gets my controllers simplified.
    [HttpPatch("{id}", Name = nameof(PatchDepartment))]

    public async Task<IActionResult> PatchDepartment([Range(1, int.MaxValue)] int id, [FromBody, ValidJsonPatchBinding] JsonPatchDocument<DepartmentForUpdateDto> patch) 

The validation attribute is using the following registered ModelValidator that handles System.ComponentModel.DataAnnotations validations and FluentValidation validations too.
public class ModelValidator : IModelValidator
{
    private readonly IServiceProvider _provider;

    public ModelValidator(IServiceProvider provider)
    {
        _provider = provider;
    }

    public bool ValidateModel(out List<ValidationResult> results, params object[] models)
    {
        results = new List<ValidationResult>();

        foreach (var model in models)
        {
            if (!Validator.TryValidateObject(model, new ValidationContext(model), results, true)) return false;                   // If the data annotations validation process founds an error ends processing.

            var genericType = typeof(IValidator<>).MakeGenericType(model.GetType());

            if (!(_provider.GetService(genericType) is IValidator validator)) continue;                                          // Gets a valid fluent validator registered to process if there is any.

            if (!(validator.Validate(model) is FluentValidation.Results.ValidationResult result) || result.IsValid) continue;

            results.AddRange(result.Errors.Select(e => new ValidationResult(e.ErrorMessage, new[] { e.PropertyName })));         // If the fluent validation process founds and error ends processing.

            return false;
        }

        return true;                                                                                                            // All models nas been validated successfully.
    }
}

public interface IModelValidator
{
    bool ValidateModel(out List<ValidationResult> results, params object[] models);
}


I Just share this code just in case meany reading this thread would benefit.
KevinDockx commented 5 years ago

Thanks for that! :)