Immutable Properties With JSON Patch in AspNet Core

Immutable Properties With JSON Patch in AspNet Core

There is now a NuGet package for this: Tingle.AspNetCore.JsonPatch.NewtonsoftJson

There are times you want to make updates to a resource on the server without replacing the whole of it. JSON Patch works a great deal in this, lighter and very powerful. To learn more on JSON Patch check out these links below:

  1. Official site at http://jsonpatch.com
  2. Introductory Article by Kevin Sookocheff
  3. Explainer by Waqat Mansor

ASP.NET Core supports JSON Patch natively using the HttpPatchAttribute usually decorate on a controller action with [HttpPatch], model binding support for JsonPatchDocument and JsonPatchDocument<T>, and model validation support when applying changes via extension method with the signature document.ApplyTo(document, ModelState). More on this can be found in the official documentation.

On this post I will not focus on how JSON Patch works, why it is better or how it is supported in ASP.NET Core (for that, see above links). Instead, I will focus on how you can ensure that certain properties are not edited in a patch operation or ensure that only certain properties can be edited.

TLDR; you can jump straight to the code.

Breakdown for parts?

There are many parts in this sample. So let’s dig in. The sample uses Entity Framework Core which I assume anyone working with ASPNET Core understands.

Modeling with JSON Patch (Vehicle.cs)

When working with JSON Patch it is a good idea to have different models for every level of information and then employ inheritance between them. For example, let us consider that the server store information about vehicles in a garage. The unique identifier (which could be the VIN, the registration number, or a randomly generated string) of the vehicle should not be changed after creation but the creator is allowed to specify it during creation. We would model 3 classes: VehiclePatchModel, VehicleCreateModel and Vehicle. Employing inheritance, VehicleCreateModel would inherit from VehiclePatchModel and Vehicle may optionally inherit from VehicleCreateModel to avoid duplicate properties, ease documentation and maintenance.

using System;
using System.ComponentModel.DataAnnotations;
 
public class VehiclePatchModel
{
  [Required]
  public string Make { get; set; }
 
  [Required]
  public string Model { get; set; }
 
  [MaxLength(10)]
  public string Displacement { get; set; }
 
  [Range(2, 8)]
  public int Doors { get; set; }
}
 
public class VehicleCreateModel : VehiclePatchModel
{
  public string Id { get; set; }
  [Required]
  public string ChasisNo { get; set; }
}
 
public class Vehicle : VehicleCreateModel
{
  public DateTimeOffset Created { get; set; }
  public DateTimeOffset Updated { get; set; }
}

These models will also be used to separate the accepted properties at different stages. In the example, the properties that are accepted for the JSON Patch operation are those in the VehiclePatchModel.

Immutable properties on JsonPatchDocument<T>

Applying JSON patch operations on a target object can fail without throwing an exception or returning an error. In ASP.NET Core, this is mitigated using an extension method that takes in the ModelState so that it can write the errors to it. This samples, makes use of this function internally by checking the properties before executing it. The logic is shown below:

using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
 
public static JsonPatchDocumentExtensions
{
  public static void ApplyToSafely<T>(this JsonPatchDocument<T> patchDoc,
                                      T objectToApplyTo,
                                      ModelStateDictionary modelState)
      where T : class
  {
      if (patchDoc == null) throw new ArgumentNullException(nameof(patchDoc));
      if (objectToApplyTo == null) throw new ArgumentNullException(nameof(objectToApplyTo));
      if (modelState == null) throw new ArgumentNullException(nameof(modelState));
 
      patchDoc.ApplyToSafely(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: string.Empty);
  }
 
  public static void ApplyToSafely<T>(this JsonPatchDocument<T> patchDoc,
                                      T objectToApplyTo,
                                      ModelStateDictionary modelState,
                                      string prefix)
      where T : class
  {
      if (patchDoc == null) throw new ArgumentNullException(nameof(patchDoc));
      if (objectToApplyTo == null) throw new ArgumentNullException(nameof(objectToApplyTo));
      if (modelState == null) throw new ArgumentNullException(nameof(modelState));
 
      // get public non-static properties up the dependency tree
      var attrs = BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance;
      var properties = typeof(T).GetProperties(attrs).Select(p => p.Name).ToList();
 
      // check each operation
      foreach (var op in patchDoc.Operations)
      {
          // only consider when the operation path is present
          if (!string.IsNullOrWhiteSpace(op.path))
          {
              var segments = op.path.TrimStart('/').Split('/');
              var target = segments.First();
              if (!properties.Contains(target, StringComparer.OrdinalIgnoreCase))
              {
                  var key = string.IsNullOrEmpty(prefix) ? target : prefix + "." + target;
                  modelState.TryAddModelError(key, $"The property at path '{op.path}' is immutable or does not exist.");
                  return;
              }
          }
      }
 
      // if we get here, there are no changes to the immutable properties
      // we can thus proceed to apply the operations
      patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: prefix);
  }
}

Checking allowed properties can be difficult. Instead of using a list of properties that are immutable, we check the properties that can be changed. In JSON Patch, paths are specified starting with a forward slash /, so we have to make sure that we remove it. Also for arrays, lists and collection, the last segment in the path specifies the index or any. For example: /0 to address index zero, /30 to address index 30, or /- to not target a particular index such as when adding a new item to a list. Removing this segments can be tricky and can cause errors.

Nested properties have a similar problem with indexes above. Our sample, assumes that all nested properties a patch model are not immutable.

Finally, to make the solution easier to use, the implementation is done using an extension method on JsonPatchDocument<T>.

Working with MVC Controller

Now that we have the models setup and the extension method, we now can need to bring the two together. This is where an MVC controller is used.

using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.EntityFrameworkCore;
 
[ApiController]
[Route("/vehicles")]
[ProducesErrorResponseType(typeof(ValidationProblemDetails))]
public class VehiclesController : ControllerBase
{
  private readonly AppDbContext dbContext;
 
  public VehiclesController(AppDbContext dbContext)
  {
    this.dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
  }
 
  [HttpGet]
  [ProducesResponseType(typeof(Vehicle[]), 200)]
  public async Task<IActionResult> ListAsync()
  {
    var vehicles = await dbContext.Vehicles.ToListAsync();
    return Ok(vehicles);
  }
 
  [HttpGet("{id}")]
  [ProducesResponseType(typeof(Vehicle), 200)]
  public async Task<IActionResult> GetAsync([FromRoute] string id)
  {
    var vehicle = await dbContext.Vehicles.SingleOrDefaultAsync(v => v.Id == id);
    return Ok(vehicle);
  }
 
  [HttpPatch("{id}")]
  [ProducesResponseType(typeof(Vehicle), 200)]
  public async Task<IActionResult> UpdateAsync([FromRoute, Required] string id, [FromBody] JsonPatchDocument<VehiclePatchModel> document)
  {
    // ensure the vehicle exists first
    var target = await dbContext.Vehicles.SingleOrDefaultAsync(v => v.Id == id);
    if (target == null)
    {
      return Problem(title: "vehicle_not_found",
                     detail: "The target vehicle does not exist",
                     status: 400);
    }
 
    // apply the changes safely
    document.ApplyToSafely(target, ModelState);
    if (!ModelState.IsValid) return Problem();
 
    // save the changes to the database
    target.Updated = DateTimeOffset.UtcNow; // update the timestamp
    await dbContext.SaveChangesAsync();
 
    return Ok(target);
  }
 
  [HttpPost]
  [ProducesResponseType(typeof(Vehicle), 200)]
  public async Task<IActionResult> CreateAsync([FromBody] VehicleCreateModel model)
  {
    if (!string.IsNullOrEmpty(model.Id))
    {
      if (await dbContext.Vehicles.AnyAsync(v => v.Id == model.Id))
      {
        return Problem(title: "vehicle_exists",
                       detail: "A vehicle with the same identifier already exists",
                       status: 400);
      }
    }
 
    // Create saveable vehicle
    var vehicle = new Vehicle
    {
      Id = model.Id ?? Guid.NewGuid().ToString(), // create one if missing
      ChasisNo = mode.ChasisNo,
      Make = model.Make,
      Model = model.Model,
      Displacement = model.Displacement,
      Doors = model.Doors,
      Created = DateTimeOffset.UtcNow,
      Updated = DateTimeOffset.UtcNow,
    };
 
    // add to dbContext and save to database
    await dbContext.Vehicles.AddAsync(vehicle);
    await dbContext.SaveChangesAsync();
    return Ok(vehicle);
  }
}

The ListAsync and GetAsync methods are straightforward. The CreateAsync method works with the VehicleCreateModel to avoid setting properties that are only system generated. This is a fairly common pattern.

The fun part is in the UpdateAsync method which takes an important JsonPatchDocument<VehiclePatchModel> . This type sets the type which contains the properties that we can update. Since we employed inheritance, we can apply JsonPatchDocument<VehiclePatchModel> to an object of type Vehicle . Easy …

Finally, when the patch document is applied on the object, we must check if the ModelState still remains valid. If there were errors in apply the JSON Patch operations, they would be written as errors in the ModelState dictionary. Returning Problem(); will pick errors from the ModelState, convert to ValidationProblemDetails and then write to the response.

Samples

To try out the sample, make a HTTP Patch request with an try to change the ChasisNo property. See SampleRequest.json for the body.

[
  {
    "op": "replace",
    "path": "/chasisNo",
    "value": "1234567890"
  },
  {
    "op": "replace",
    "path": "/make",
    "value": "Mercedes"
  }
]

The first operation attempts to change an immutable property causing validation to fail and receiving a 400 (Bad Request) response. See SampleResponse.json for the body.

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1"
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "chasisNo": [
      "The property at path '/chasisNo' is immutable or does not exist."
    ]
  },
  "traceId": "|ff211a43-456dc8484f1a0fb7."
}

Removing the first operation from the request would result in completion of the request. This samples assumes that you have data in the database context before attempting a patch. If not, you can replace the use of Entity Framework with a static list of items for test purposes.

Conclusion

Working with JSON Patch can be awesome and painful at the same time. Hopefully, I have helped you make it easier with some sort of rules on what can be edited.

There are still some missing pieces on here:

  1. If there are many projects such as a solution with micro-services, repeating this can be tedious so it may be better to create a library that moves the repeated code out of the application project.
  2. If there are many types that are to be edited, one may consider moving the code out of the controller to a Validation attribute, model binding extensions or just somewhere else.
  3. The sample here may not cover all scenarios for nested properties.