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
nullinstead of deleting it testa value first and abort the whole patch if it is not what the client last saw
| GitHub | |
| Downloads |
dotnet add package Shiny.AspNet.JsonPatchPATCH /api/notes/7Content-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.
As a minimal-API parameter
Section titled “As a minimal-API parameter”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 otherContent-Typewith 415 before the handler runs. A JSON array sent as plainapplication/jsonis 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-Typepicks 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.
In a RequestDelegate handler
Section titled “In a RequestDelegate handler”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.
Unhandled exceptions
Section titled “Unhandled exceptions”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.
Applying
Section titled “Applying”JsonNode? result = patch.ApplyTo(document); // any JSONNote patched = patch.ApplyTo(note, AppJson.Default.Note); // a typed value, via its JsonTypeInfo- All or nothing.
ApplyTonever 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.addorreplaceat the root swaps the document out, soApplyTo(JsonNode?)can return a different kind of node than it was given.removeat the root is refused as malformed, because RFC 6902 does not define it and implementations disagree. testequality follows RFC 6902 §4.6. Numbers compare by value (1,1.0and1e0are equal), object members compare in any order, strings compare exactly after unescaping, and"10"is never equal to10.
Building one in code
Section titled “Building one in code”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.
Errors
Section titled “Errors”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.
JSON Pointer
Section titled “JSON Pointer”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 nullJsonPointer.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.
Testing
Section titled “Testing”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.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.


