PATCH when working with DTO
Asked Answered
F

4

17

I'm working on asp.net core webAPi and EF core, and want to implement "update" operation (partially edit entity). I searched the correct way to deal with that, and saw that I should use jsonPatch. the problem is that I'm expose just DTOs through my API, and if I use jsonPatch like:

public AccountDto Patch(int id, [FromBody]JsonPatchDocument<AccountDto> patch)

then I need to apply the patch on DTO, and I can't apply it on the model entity, without create a new entity.

I also read about Odata.Delta, but it still not work on asp.net core, and furthermore - I don't think it has a built in solution for working with dto (I found this example that can help when Odata for core will be available)

So, for now - should I use POST and send DTO with list of changed properties in query (as I saw here), Or - there is more elegant solution?

Thanks!

Frida answered 19/7, 2016 at 20:1 Comment(1)
Take a look at this solution: #39452554Quickie
F
10

Now I saw that using autoMapper I can do just

CreateMap<JsonPatchDocument<AccountDTO>, JsonPatchDocument<Account>>();
        CreateMap<Operation<AccountDTO>, Operation<Account>>();

and it work like a charm :)

Frida answered 31/7, 2016 at 19:2 Comment(0)
W
5

This works great for any <T>

CreateMap(typeof(JsonPatchDocument<>), typeof(JsonPatchDocument<>));
CreateMap(typeof(Operation<>), typeof(Operation<>));
Wethington answered 20/8, 2019 at 15:36 Comment(0)
F
4

Eventually,

I just remove the type from JsonPatchDocument, and saw that it can work without type...

[HttpPatch("{id}")]
    public AccountDTO Patch(int id, [FromBody]JsonPatchDocument patch)
    {
        return _mapper.Map<AccountDTO>(_accountBlService.EditAccount(id, patch));
    }

And then, In BL layer,

public Account EditAccount(int id, JsonPatchDocument patch)
    {
        var account = _context.Accounts.Single(a => a.AccountId == id);
        var uneditablePaths = new List<string> { "/accountId" };

        if (patch.Operations.Any(operation => uneditablePaths.Contains(operation.path)))
        {
            throw new UnauthorizedAccessException();
        }
        patch.ApplyTo(account);            
        return account;
    }
Frida answered 23/7, 2016 at 21:55 Comment(3)
Note that if u use it so, you can't use ModelState like 'patch.ApplyTo(accountToTestValidation, ModelState);'Frida
but I still can manually check the patched object validation by patch.ApplyTo(patched); TryValidateModel(patched);Frida
Amazing - Was stuck on this for an hourPintail
C
4

Use the DTO as an "external contract" of your endpoint only, the check that all is ok on your DTO and on your patch document, use the operations to build a dictionary of replace operations to perform, build and expando object with those operations (property, value), use a custom automapper anonymous mapper and solve..

I will export some code of how has been done on a more complex example

Controller action...

[HttpPatch("{id}", Name = nameof(PatchDepartment))]
[HttpCacheFactory(0, ViewModelType = typeof(Department))]
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);

    var dto = new DepartmentForUpdateDto();

    patch.ApplyTo(dto, 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);

    TryValidateModel(dto);

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

    var result = await _mediator.Send(new EditDepartmentCommand(id, patch.Operations.Where(o => o.OperationType == OperationType.Replace).ToDictionary(r => r.path, r => r.value))).ConfigureAwait(false);

    if (result.IsFailure && result.Value == StatusCodes.Status400BadRequest) return StatusCode(StatusCodes.Status404NotFound, result.Error);

    if (result.IsFailure && result.Value == StatusCodes.Status404NotFound) return StatusCode(StatusCodes.Status404NotFound, result.Error);

    if (result.IsFailure) return StatusCode(StatusCodes.Status500InternalServerError, result.Error);             // StatusCodes.Status500InternalServerError will be triggered by DbUpdateConcurrencyException.

    return NoContent();
}

MediatR Commmand and CommandHandler

public sealed class EditDepartmentCommand : IRequest<Result<int>>
{
    public int Id { get; }
    public IDictionary<string, object> Operations { get; }

    public EditDepartmentCommand(int id, IDictionary<string, object> operations) // (*) We avoid coupling this command to a JsonPatchDocument<DepartmentForUpdateDto> "contract" passing a dictionary with replace operations.
    {
        Id = id;
        Operations = operations;
    }
}

public sealed class EditDepartmentHandler : BaseHandler, IRequestHandler<EditDepartmentCommand, Result<int>>
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IAnonymousMapper _mapper;

    public EditDepartmentHandler(IUnitOfWork unitOfWork, IAnonymousMapper mapper)
    {
        _mapper = mapper;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<int>> Handle(EditDepartmentCommand command, CancellationToken token)
    {
        using (var repository = _unitOfWork.GetRepository<Department>())
        {
            var department = await repository.FindAsync(command.Id, true, token).ConfigureAwait(false);

            if (department == null) return Result.Fail($"{nameof(EditDepartmentHandler)} failed on edit {nameof(Department)} '{command.Id}'.", StatusCodes.Status404NotFound);   // We could perform a upserting but such operations will require to have guids as primary keys.

            dynamic data = command.Operations.Aggregate(new ExpandoObject() as IDictionary<string, object>, (a, p) => { a.Add(p.Key.Replace("/", ""), p.Value); return a; });    // Use an expando object to build such as and "anonymous" object.

            _mapper.Map(data, department);                                                                                                                                       //  (*) Update entity with expando properties and his projections, using auto mapper Map(source, destination) overload.

            ValidateModel(department, out var results);

            if (results.Count != 0)
                return Result.Fail($"{nameof(EditDepartmentHandler)} failed on edit {nameof(Department)} '{command.Id}' '{results.First().ErrorMessage}'.", StatusCodes.Status400BadRequest);

            var success = await repository.UpdateAsync(department, token: token).ConfigureAwait(false) &&                                                                        // Since the entity has been tracked by the context when was issued FindAsync
                          await _unitOfWork.SaveChangesAsync().ConfigureAwait(false) >= 0;                                                                                       // now any changes projected by auto mapper will be persisted by SaveChangesAsync.

            return success ?
                Result.Ok(StatusCodes.Status204NoContent) :
                Result.Fail<int>($"{nameof(EditDepartmentHandler)} failed on edit {nameof(Department)} '{command.Id}'.");
        }
    }

}

public abstract class BaseHandler
{
    public void ValidateModel(object model, out ICollection<ValidationResult> results)
    {
        results = new List<ValidationResult>();

        Validator.TryValidateObject(model, new ValidationContext(model), results, true);
    }
}

The anonymous mapper

public interface IAnonymousMapper : IMapper
{
}


public class AnonymousMapper : IAnonymousMapper
{
    private readonly IMapper _mapper = Create();

    private static IMapper Create()
    {
        var config = new MapperConfiguration(cfg =>
        {
            cfg.ValidateInlineMaps = false;
            cfg.CreateMissingTypeMaps = true;
            //cfg.SourceMemberNamingConvention = 
           // cfg.DestinationMemberNamingConvention = new PascalCaseNamingConvention();
        });

        return config.CreateMapper();
    }

    public TDestination Map<TDestination>(object source) => _mapper.Map<TDestination>(source);
    public TDestination Map<TDestination>(object source, Action<IMappingOperationOptions> opts) => _mapper.Map<TDestination>(source, opts);
    public TDestination Map<TSource, TDestination>(TSource source) => _mapper.Map<TSource, TDestination>(source);
    public TDestination Map<TSource, TDestination>(TSource source, Action<IMappingOperationOptions<TSource, TDestination>> opts) => _mapper.Map(source, opts);
    public TDestination Map<TSource, TDestination>(TSource source, TDestination destination) => _mapper.Map(source, destination);
    public TDestination Map<TSource, TDestination>(TSource source, TDestination destination, Action<IMappingOperationOptions<TSource, TDestination>> opts) => _mapper.Map(source, destination, opts);
    public object Map(object source, Type sourceType, Type destinationType) => _mapper.Map(source, sourceType, destinationType);
    public object Map(object source, Type sourceType, Type destinationType, Action<IMappingOperationOptions> opts) => _mapper.Map(source, sourceType, destinationType, opts);
    public object Map(object source, object destination, Type sourceType, Type destinationType) => _mapper.Map(source, destination, sourceType, destinationType);
    public object Map(object source, object destination, Type sourceType, Type destinationType, Action<IMappingOperationOptions> opts) => _mapper.Map(source, destination, sourceType, destinationType);
    public IQueryable<TDestination> ProjectTo<TDestination>(IQueryable source, object parameters = null, params Expression<Func<TDestination, object>>[] membersToExpand) => _mapper.ProjectTo(source, parameters, membersToExpand);
    public IQueryable<TDestination> ProjectTo<TDestination>(IQueryable source, IDictionary<string, object> parameters, params string[] membersToExpand) => _mapper.ProjectTo<TDestination>(source, parameters, membersToExpand);
    public IConfigurationProvider ConfigurationProvider => _mapper.ConfigurationProvider;
    public Func<Type, object> ServiceCtor => _mapper.ServiceCtor;
}
Centra answered 16/2, 2019 at 22:39 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.