14
votes

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!

4
Take a look at this solution: stackoverflow.com/questions/39452054/…Joshit

4 Answers

9
votes

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 :)

4
votes

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;
    }
4
votes

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;
}
1
votes

This works great for any <T>

CreateMap(typeof(JsonPatchDocument<>), typeof(JsonPatchDocument<>));
CreateMap(typeof(Operation<>), typeof(Operation<>));