Skip to content

Instantly share code, notes, and snippets.

@mburumaxwell
Last active June 21, 2022 21:50
Show Gist options
  • Save mburumaxwell/01a21a2d1581ac3c099a5ffb54555804 to your computer and use it in GitHub Desktop.
Save mburumaxwell/01a21a2d1581ac3c099a5ffb54555804 to your computer and use it in GitHub Desktop.
Immutable properties using JSON Patch in ASP.NET Core
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 propeties 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);
}
}
[
{
"op": "replace",
"path": "/chasisNo",
"value": "1234567890"
},
{
"op": "replace",
"path": "/make",
"value": "Mercedes"
}
]
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1"
"title": "One or more validation errors occured.",
"status": 400,
"errors": {
"chasisNo": [
"The property at path '/chasisNo' is immutable or does not exist."
]
},
"traceId": "|ff211a43-456dc8484f1a0fb7."
}
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; }
}
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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment