Last active
June 21, 2022 21:50
-
-
Save mburumaxwell/01a21a2d1581ac3c099a5ffb54555804 to your computer and use it in GitHub Desktop.
Immutable properties using JSON Patch in ASP.NET Core
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"op": "replace", | |
"path": "/chasisNo", | |
"value": "1234567890" | |
}, | |
{ | |
"op": "replace", | |
"path": "/make", | |
"value": "Mercedes" | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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." | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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