Back in the late 2000s, when I was graduating from university and was playing with PHP, I only knew GET and POST operations. Some years later, while switching from developing desktop applications and WPF to Web apps and Web API, I got familiar with PUT and DELETE HTTP requests. Recently, I’ve been involved in the project extensively using HTTP Patch to update business objects states.
While adding this feature to any asp.net core APIs might seem trivial, it is far from it. I want to share what I learned and help you decide if you should adopt this solution in your application. But before I get into details, I would like to explain what Patch exactly is.
PUT VS PATCH
Both PUT and PATCH are very similar but at the same time very different. PUT lets you perform update operations on the whole business object while PATCH focuses on performing updates on one or more properties. To show you the difference I will use the example below.
Let’s say we have the business object of the Rectangle which has two properties: length and width. When using PUT operation if you try to change only one of the dimensions in your API request the second one will be set to null or 0, depending on the model definition. So in order for it to work correctly when composing the request body, you have to send the original value of the dimension which you don’t want to change.
This is the moment when PATCH operation comes into handy. You are allowed to define update operation only on one property of the API contract. If you define PATCH that will change only the length, the width will stay intact. This is how it happens.
HINT
A PATCH is not necessarily idempotent, although it can be. In contrast, PUT is always idempotent. Idempotent means that no matter how many times you execute an operation the result will always be the same.
JSON PATCH
JSON Patch defines a JSON document structure for expressing a sequence of operations to apply to a JavaScript Object Notation (JSON) document. It is defined in RFC 6902.
TODO:
JSON Patch Example
[
{
"op": "replace",
"path": "/name",
"value": "Green house"
},
{ "op": "add",
"path": "/rooms/-",
"value": {
"name": "Bedroom",
"color": "Blue"
}
}
]
Each patch operation defines its type “op” which will be used to manipulate the data, “path” to the object’s property which we would like to update, and a new “value” for this property.
JSON patch specification defines several allowed operations:
– add for adding a property to the object or element to the array. If the property exists it will set its value,
– remove removes property or element from an array,
– replace it is composed of remove operation followed by add,
– move removes a property or array element defined in “from” and adds it at “path” using the source value,
– copy it adds a property or element to the array using the value from the source,
– test checks if the value at “path” is equal to the provide value.
This is the first moment when things get tricky. You may ask yourself “but wait how can I remove property from an object in strictly typed language like C#” … well with some exceptions like ExpandoObject you can’t. It will use the default value in case of value types, and null in the case of reference types. This of course may lead to an undesired change in the business model. A similar problem occurs when you try to add a non-existing property. In this case, the operation will fail.
Adding Patch to the project
Now when we have a bit more knowledge about JSON Patch we can start implementing our first API endpoint. In order to allow our application to start serving HttpPatch endpoints, we must first add Microsoft.AspNetCore.Mvc.NewtonsoftJson package reference. The second required step is to register all necessary services in the Dependency Injection container using the AddNewtonsoftJson extension method from Microsoft.Extensions.DependencyInjection.
services.AddControllers()
.AddNewtonsoftJson();
WARNING
Without the last step, the endpoint will return BadRequest stating that “The JSON value could not be converted to Microsoft.AspNetCore. JsonPatch.JsonPatchDocument”.
Expose Patch on endpoint
We can finally add [HttpPatch] attribute to our controller method that will enable usage of HTTP Patch to the clients. In the example below you can see the required method signature.
[HttpPatch("{id}")]
Task UpdateHouse([FromBody] JsonPatchDocument patchDocument, Guid id)
The element which we didn’t discuss yet is JsonPatchDocument<TModel>. The JsonPatchDocument class allows you to define all described operation types and most important to apply all of these operations to TModel.
HINT
Maybe it is obvious but it’s worth mentioning that I strongly advise you to define a separate and safe model as an endpoint’s contract instead of using the domain model directly. By safe I mean that it will not contain properties with sensitive data like passwords etc. or the data which we would like to avoid changing.
Service Layer
The typical implementation of Patch operation in the service layer will involve acquiring the business object from the data access layer, projecting the domain model to the API model, applying Patch onto it, validating the model after the change, and reapplying the changes to the business object before we persist it in the database.
public class HouseService : IHouseService
{
private readonly IRepository _repository;
private readonly IMapper _mapper;
public HouseService(IRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task UpdateHouse(
JsonPatchDocument patch,
Guid id)
{
_ = id == Guid.Empty ? throw new ArgumentException(nameof(id)) : id;
_ = patch ?? throw new ArgumentNullException(nameof(patch));
var dataModelHouse = await _repository.Get()
.Include(x => x.Address)
.FirstOrDefaultAsync(x => x.Id == id) ??
throw new KeyNotFoundException($"There is no {nameof(House)} with Id: {id}");
var apiModelHouse = _mapper.Map(dataModelHouse);
patch.ApplyTo(apiModelHouse);
_mapper.Map(apiModelHouse, dataModelHouse);
_repository.Update(dataModelHouse);
await _repository.SaveChangesAsync();
}
}
HINT
The projection of the business model to DTO is an essential precaution not to expose it directly to the API consumer. This way we narrow down the range of changes available for processing by Patch operation.
Validation
The last and by far the most important step of executing the Patch operation is Validation. We must confirm that the change done by the operation did not invalidate the state of our model.
Not less important concern should be if we would like to allow all operation types on our patch operation. For example, we may not allow to apply { “op” : “remove” … } to value typed properties as it will set it to the default value.
When not to use JSON Patch
The last topic I would like to discuss is working with Patch operations for properties that are collections. The problem with JSON Patch is it assumes that this property is an array. Because of that, adding elements to it or replacing the whole collection works quite fine but it is practically impossible to remove or replace/modify the single element. You can only indicate its index while you cannot guarantee the resulting position of the element on the collection retrieved from the database. The workaround of this problem might be possible but it would stay in contradiction to the RFC standard.
Contract binding and model state problems
I should also point out one of the costly disadvantages of JSON Patch implementation on ASP.NET Core (verified on .Net 5 ).
In the controller method as one of the parameters you define JsonPatchDocument<TModel> and despite the fact that you define a strict type parameter the data binding will set ModelState.IsValid to “true” even when you try to patch non existing property. You will finally get a JsonPatchException but from ApplyTo call while you already unnecessarily retrieved the business model from the database. You can check that by executing one of the Integration tests that are part of the example project attached to this article.
Source code
You can find an example application using JSON Patch containing both Unit and Integration tests projects to play around on my GitHub.