Introducing Corvus.Text.Json V5: JSON Patch
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at YAML 1.2 conversion.
Now let's look at a natural companion to the mutable document model: JSON Patch.
Why JSON Patch?
APIs commonly need to accept partial updates. That means changing a subset of fields in a document without resending the whole thing. RFC 6902 JSON Patch is the standard convention for expressing those changes. It defines a JSON format for describing a sequence of operations to apply to a document, and it's widely supported across languages and frameworks.
A patch is a JSON array of operations:
[
{ "op": "replace", "path": "/name", "value": "Bob" },
{ "op": "add", "path": "/email", "value": "bob@example.com" },
{ "op": "remove", "path": "/age" }
]
Each operation specifies what to do (op), where to do it (path in RFC 6901 JSON Pointer syntax), and optionally a value or from path. All six RFC 6902 operations are supported: add, remove, replace, move, copy, and test.
Quick start
dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.Patch
Build a patch with the fluent API and apply it:
using Corvus.Text.Json;
using Corvus.Text.Json.Patch;
using JsonWorkspace workspace = JsonWorkspace.Create();
using var builder = JsonDocumentBuilder<JsonElement.Mutable>.Parse(
workspace,
"""{"name": "Alice", "age": 30}""");
JsonElement.Mutable root = builder.RootElement;
using PatchBuilder patchBuilder = root.BeginPatch(workspace)
.Replace("/name"u8, "Bob"u8)
.Add("/email"u8, "bob@example.com"u8)
.Remove("/age"u8);
JsonPatchDocument patch = patchBuilder.GetPatchAndDispose();
bool success = root.TryApplyPatch(in patch);
Console.WriteLine(builder.RootElement);
// {"name":"Bob","email":"bob@example.com"}
A few things to notice here:
BeginPatch(workspace)takes the caller's workspace. The returnedJsonPatchDocumentis backed by that workspace - you must keep the workspace alive for the lifetime of the patch.GetPatchAndDispose()finalises the patch and disposes the builder's internal resources, signalling that the builder is no longer in use. After this call, the builder must not be reused.- The
usingon the builder is a safety net: if an exception is thrown during the fluent chain (beforeGetPatchAndDispose()is called), theusingensures the builder's resources are still cleaned up. BecauseDispose()is idempotent, calling it afterGetPatchAndDispose()is harmless.
Applying patches from external sources
When a patch arrives from an API request or a file and you haven't validated it, use TryValidateAndApplyPatch. This validates the patch document against the RFC 6902 JSON Schema before applying any operations:
using ParsedJsonDocument<JsonPatchDocument> patchDoc = ParsedJsonDocument<JsonPatchDocument>.Parse(
"""
[
{ "op": "replace", "path": "/name", "value": "Charlie" },
{ "op": "add", "path": "/active", "value": true }
]
""");
bool success = root.TryValidateAndApplyPatch(patchDoc.RootElement);
If you constructed the patch locally via PatchBuilder, you can skip validation and call TryApplyPatch directly.
Individual operations
Each operation is also available as a standalone extension method on JsonElement.Mutable (defined in JsonPatchExtensions), for when you need a single change without constructing a full patch document. Values accept JsonElement.Source, which has implicit conversions from string, ReadOnlySpan<byte>, bool, int, double, and many more types.
Add
Adds a value at the target path. For objects, the property is created (or replaced if it already exists). For arrays, the value is inserted at the given index. The special index - appends to the end.
root.TryAdd("/email"u8, "alice@example.com"u8);
root.TryAdd("/tags/0"u8, "important"u8);
root.TryAdd("/tags/-"u8, "new-tag"u8);
Remove
Removes the value at the target path. The target must exist.
root.TryRemove("/email"u8);
root.TryRemove("/tags/0"u8);
Replace
Replaces the value at the target path. Unlike add, the target must already exist.
root.TryReplace("/name"u8, "Bob"u8);
root.TryReplace("/age"u8, 31);
Move
Moves a value from one path to another. This is equivalent to a remove followed by an add.
root.TryMove("/old_name"u8, "/name"u8);
Copy
Copies a value from one path to another without removing the source.
root.TryCopy("/name"u8, "/display_name"u8);
Test
Tests that the value at the target path equals the expected value using deep equality. Returns true if they match. No mutation is performed.
using ParsedJsonDocument<JsonElement> expected = ParsedJsonDocument<JsonElement>.Parse("\"Alice\"");
bool matches = root.TryTest("/name"u8, expected.RootElement);
This is useful for conditional patches. If the test fails, the entire patch fails:
using PatchBuilder patchBuilder = root.BeginPatch(workspace)
.Test("/version"u8, 1) // guard: only apply if version is 1
.Replace("/version"u8, 2) // update version
.Add("/migrated"u8, true); // add new field
JsonPatchDocument patch = patchBuilder.GetPatchAndDispose();
bool success = root.TryApplyPatch(in patch);
// success is false if /version was not 1
JSON Pointer paths
All paths follow RFC 6901 JSON Pointer syntax:
| Path | Meaning |
|---|---|
"" |
The root document |
"/name" |
The name property of the root object |
"/address/city" |
Nested property access |
"/tags/0" |
First element of the tags array |
"/tags/-" |
Past-the-end position (for append) |
"/a~1b" |
Property named a/b (escaped forward slash) |
"/m~0n" |
Property named m~n (escaped tilde) |
Path-accepting methods have three overloads: ReadOnlySpan<byte> (UTF-8, preferred), ReadOnlySpan<char>, and string. For best performance, use the "..."u8 byte literal form.
Error handling
All Try* methods return bool. When TryApplyPatch returns false, operations applied before the failure are not rolled back. The document is in a partially-modified state. If you need atomic all-or-nothing semantics, take a snapshot before applying and restore it on failure. Remember CreateSnapshot() and Restore() from Part 5? That's exactly what they're for:
using var snapshot = builder.CreateSnapshot();
bool success = root.TryApplyPatch(in patch);
if (!success)
{
builder.Restore(snapshot);
}
Next up
In the next post, we'll look at JSON Pointer resolution. It's the path syntax that JSON Patch uses under the hood, and V5 exposes it as a first-class zero-allocation API through Utf8JsonPointer.