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