Skip to content
Shiny.NET

JSON Patch

PATCH with a merge-patch body (RFC 7396) can only say “this is what the object should look like now”. JSON Patch (RFC 6902) is a list of operations, and it can say several things merge patch cannot:

  • append to an array (/tags/-) or remove one element from it (/tags/2)
  • move or copy a value from one place to another
  • set a member to JSON null instead of deleting it
  • test a value first and abort the whole patch if it is not what the client last saw
GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.JsonPatch
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.JsonPatch
PATCH /api/notes/7
Content-Type: application/json-patch+json
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/title", "value": "Groceries" },
{ "op": "add", "path": "/tags/-", "value": "home" },
{ "op": "remove", "path": "/draft" }
]

It is built on System.Text.Json.Nodes and uses no reflection, so it is trim and Native AOT clean.

A JsonPatchDocument parameter binds itself from the body (it implements BindAsync, which the Request Delegate Generator supports, so this stays AOT-clean). Apply it to the current value with that type’s source-generated metadata:

using Shiny.AspNet.JsonPatch;
builder.Services.AddProblemDetails();
builder.Services.AddJsonPatch(); // full problem bodies for unhandled JsonPatchExceptions
var app = builder.Build();
app.UseExceptionHandler();
app.MapPatch("/api/notes/{id:int}", (int id, JsonPatchDocument patch, INoteStore notes) =>
{
if (notes.Find(id) is not { } note)
return Results.NotFound();
try
{
var patched = patch.ApplyTo(note, AppJson.Default.Note);
notes.Save(patched);
return Results.Json(patched, AppJson.Default.Note);
}
catch (JsonPatchException ex)
{
return ex.ToResult(); // 409 / 422 problem naming the operation that failed
}
});

The parameter is treated differently from an ordinary JSON body:

  • The body must be application/json-patch+json. The parameter declares that media type as endpoint metadata, so ASP.NET routing answers any other Content-Type with 415 before the handler runs. A JSON array sent as plain application/json is not assumed to be a patch, because on a resource that also accepts merge patch the two readings of the same body write different things.

  • Because routing knows the media type, a merge-patch endpoint can share the route and the Content-Type picks between them:

    app.MapPatch("/notes/{id}", (string id, JsonPatchDocument patch) => ...);
    app.MapPatch("/notes/{id}", (string id, JsonObject merge) => ...)
    .Accepts<JsonObject>("application/merge-patch+json");
  • A missing or malformed patch is a 400 problem naming the operation at fault. The handler is not called.

  • It needs no [JsonSerializable] entry. The patch is parsed by hand.

  • OpenAPI sees the request body as application/json-patch+json.

app.MapPatch("/notes/{id}", async (HttpContext ctx) =>
{
try
{
var patch = await ctx.Request.ReadJsonPatchAsync(ctx.RequestAborted);
var note = notes.Find(int.Parse(ctx.Request.RouteValues["id"]!.ToString()!));
notes.Save(patch.ApplyTo(note, AppJson.Default.Note));
await Results.NoContent().ExecuteAsync(ctx);
}
catch (JsonPatchException ex)
{
await ex.ToResult().ExecuteAsync(ctx);
}
});

ReadJsonPatchAsync is strict about the Content-Type (415, with Accept-Patch: application/json-patch+json), and throws for an empty or malformed body (400). ctx.Request.IsJsonPatch() checks the header without reading the body, which is useful when one PATCH route accepts more than one format.

JsonPatchException derives from ASP.NET Core’s BadHttpRequestException, carrying the status for its kind. So if one escapes a handler, everything in ASP.NET that already understands that type — Kestrel, the developer exception page, the exception-handler middleware — answers with its status rather than a 500, even with no extra setup.

services.AddJsonPatch() registers an IExceptionHandler that goes one step further and writes the full problem (operation index, op, path, and Accept-Patch on a 415). Exception handlers only run inside app.UseExceptionHandler(), so add that too. Catching the exception and returning ToResult() gives the same response per handler.

JsonNode? result = patch.ApplyTo(document); // any JSON
Note patched = patch.ApplyTo(note, AppJson.Default.Note); // a typed value, via its JsonTypeInfo
  • All or nothing. ApplyTo never modifies what you pass in. It works on a copy and returns the copy only when every operation succeeded, so a patch that fails at operation 5 has not applied operations 0 to 4 to anything. This is the atomicity RFC 6902 §5 requires.
  • Typed values round-trip through JSON with the metadata you pass. Member names are the ones the client sees, so a camel-case naming policy means /title, not /Title. The input instance is not changed. If the result no longer deserializes as the type (a string where a number belongs, or the object replaced with an array), you get a 422.
  • The root. "path": "" is the whole document. add or replace at the root swaps the document out, so ApplyTo(JsonNode?) can return a different kind of node than it was given. remove at the root is refused as malformed, because RFC 6902 does not define it and implementations disagree.
  • test equality follows RFC 6902 §4.6. Numbers compare by value (1, 1.0 and 1e0 are equal), object members compare in any order, strings compare exactly after unescaping, and "10" is never equal to 10.

The method names and argument order match ASP.NET Core’s JsonPatchDocument. The difference is that paths here are JSON Pointers, not expression trees, because turning an expression into a property name needs reflection.

var patch = new JsonPatchDocument()
.Test("/version", 3)
.Replace("/title", "Groceries")
.Add("/tags/-", "home")
.Move("/draft", "/archived")
.Remove("/scratch");
await http.PatchAsync(url, new StringContent(patch.ToJsonString(), Encoding.UTF8, JsonPatchDocument.MediaType));

JsonPatchDocument has a JsonConverter, so it can also be a property of a larger request type in your own JsonSerializerContext.

Each failure has the status RFC 5789 §2.2 gives it. The status tells the client what to do next: fix the patch, re-read the resource, or fix the result.

JsonPatchErrorKind Status When
Malformed 400 Not a JSON array, an unknown or wrongly-cased op, a missing path/from/value, an invalid pointer, a duplicate member, a move into its own child
PathNotFound 409 A location the operation needs does not exist: a missing member, an index past the end, a leading-zero index like 01, - anywhere except as the target of an add, or a pointer that goes through a string
TestFailed 409 A test compared unequal
InvalidResult 422 The patch applied, but the result is not a valid representation (it no longer fits the type, or a document that must be an object became something else)
UnsupportedMediaType 415 The body was not application/json-patch+json. Sent with Accept-Patch

PathNotFound and TestFailed are 409 Conflict, not 422. The patch is valid, but the resource is not in the state it expected, which is RFC 5789’s “conflicting state”. The problem body names the operation:

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Conflict",
"status": 409,
"detail": "Operation 0 ('test' at '/version') failed: the value is 4, not 3.",
"patchError": "testFailed",
"operationIndex": 0,
"op": "test",
"path": "/version"
}

Branch on patchError, not on the wording of detail, which can change. Problems are written through IProblemDetailsService when one is registered, so AddProblemDetails(o => o.CustomizeProblemDetails = …) shapes them like every other problem your app returns.

JsonPointer is the RFC 6901 half, and you can use it on its own:

var pointer = JsonPointer.Parse("/a~1b/m~0n/0"); // segments: "a/b", "m~n", "0"
pointer.TryEvaluate(document, out var value); // false when absent; true + null for a JSON null
JsonPointer.Create("a/b", "~").ToString(); // "/a~1b/~0"

It handles the cases hand-written pointer code usually gets wrong. ~01 decodes to ~1, not /. "" is the whole document, and / is the member whose name is the empty string. 01 is not an array index. TryEvaluate distinguishes a member that holds null from a member that does not exist.

Compared with Microsoft.AspNetCore.JsonPatch.SystemTextJson

Section titled “Compared with Microsoft.AspNetCore.JsonPatch.SystemTextJson”

.NET 10 ships Microsoft.AspNetCore.JsonPatch.SystemTextJson as a separate NuGet package. Both read the same RFC 6902 wire format; they are built for different jobs:

Microsoft’s package Shiny.AspNet.JsonPatch
Applies to CLR objects, through reflection-based adapters (JsonPatchDocument<T>.ApplyTo(T)) JSON (JsonNode), or a typed value round-tripped through its source-generated JsonTypeInfo<T>
Trim / Native AOT Reflection over the target type No reflection — analyzer-clean
Building in code Expression trees (x => x.Title) JSON Pointer strings ("/title")
Atomicity Mutates the target object in place, operation by operation Works on a copy; returns the result only when every operation succeeded
Failures An error callback / exception with a message JsonPatchException with a kind, the RFC 5789 status, the failing operation’s index, and an RFC 9457 problem
Binding Body parameter as application/json Binds itself as application/json-patch+json only, and routing enforces it
JSON Pointer Internal Public JsonPointer, usable on its own

If you patch EF entities in a JIT-compiled app and want expression-tree builders, Microsoft’s package fits. If you need a trimmed or Native AOT app, all-or-nothing application, or operation-level error reporting, use this one.

The engine is tested against every example in RFC 6902 Appendix A, every example in RFC 6901 §5, and cases from the json-patch-tests suite. The HTTP behaviour is tested on TestServer, plus Kestrel for the no-exception-handler status path.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill