@sdxc/json-patch
Apply, validate and diff RFC 6902 JSON Patch documents
- Installs with
- @sdxc/cli@sdxc/json-pointer@sdxc/result
- Used by
- sdxc-api
- Source
- packages/json-patch
Apply, validate and diff RFC 6902 JSON Patch documents.
A JSON Patch is an ordered list of operations —
add, remove, replace, move, copy and test — each aimed at an
RFC 6901 JSON Pointer. A patch applies all or
nothing: when one operation fails, the document is left exactly as it was.
It complements @sdxc/merge-patch. A merge
patch sends a partial document, which is the simplest update to write but cannot set a member to
null, edit one array element or make a change conditional. JSON Patch sends operations, so it
inserts and removes array elements, writes an explicit null, and guards an update with test
preconditions.
Installation
npm add @sdxc/json-patchFallible calls return @sdxc/result values.
Usage
Apply a patch
import { apply } from "@sdxc/json-patch";
apply({ title: "Draft", tags: ["a"] }, [
{ op: "test", path: "/title", value: "Draft" },
{ op: "replace", path: "/title", value: "Hello" },
{ op: "add", path: "/tags/-", value: "b" },
]);
// success({ title: "Hello", tags: ["a", "b"] })Compute a patch
import { diff } from "@sdxc/json-patch";
diff({ tags: ["a", "c"], note: "x" }, { tags: ["a", "b", "c"], note: null });
// [
// { op: "add", path: "/tags/1", value: "b" },
// { op: "replace", path: "/note", value: null },
// ]Read a request body
import { parse } from "@sdxc/json-patch";
import { isFailure } from "@sdxc/result";
let patch = parse(await request.text());
if (isFailure(patch)) return new Response(patch.error.message, { status: 400 });Command line
bunx @sdxc/json-patch --helpWith @sdxc/kit installed, sdxc pkg json-patch … runs the same command line, using the version your project installs.
apply prints the patched document and exits 1 naming the operation whose path or test
failed, diff prints the patch between two documents, and validate exits 1 when a file is not
a patch. A - or missing document argument reads stdin.
bunx @sdxc/json-patch apply patch.json article.json > patched.json
bunx @sdxc/json-patch diff before.json after.json --compact
bunx @sdxc/json-patch validate patch.json --jsonAPI
apply(document: JSONValue, patch: readonly Operation[]): Result<JSONValue, JsonPatchError>
Runs the operations in order against a copy of document and returns the patched copy, which
shares no object or array with either input. The patch is validated first, even when typed, so a
value cast from untrusted JSON is still checked before anything runs.
diff(source: JSONValue, target: JSONValue): Operation[]
A patch for which apply(source, patch) returns target. Objects compare member by member
regardless of order, an array keeps its shared leading and trailing elements and edits only the
span between them, and equal documents give [].
validate(value: unknown): Result<Operation[], JsonPatchError>
Checks that a value is a patch document — an array of operations with a known op, JSON Pointer
path and from members, and a value member where the operation needs one — and returns the
operations without any unrecognized members.
parse(text: string): Result<Operation[], JsonPatchError>
JSON.parse followed by validate.
stringify(patch: readonly Operation[]): string
Writes a patch as the JSON text a request body carries.
MEDIA_TYPE
"application/json-patch+json".
JsonPatchError
Every failure. index is the failing operation's position, or null when the document as a
whole is not a patch, and reason picks the RFC 5789 answer:
reason | Meaning | Status |
|---|---|---|
"invalid-patch" | The text or an operation is malformed | 400 |
"unresolvable" | A path or from has nothing to act on | 422 |
"test-failed" | A test operation's value did not match | 409 |
Types
Operation
The union of AddOperation, RemoveOperation, ReplaceOperation, MoveOperation,
CopyOperation and TestOperation. JSONPatch is Operation[].
JSONValue / JSONObject
The JSON values every function reads and writes.
JsonPatchErrorReason
"invalid-patch" | "unresolvable" | "test-failed".
Pattern: Accept JSON Patch On A PATCH Endpoint
The handler checks the media type, applies the patch to the stored resource and maps each failure reason to its status.
import { apply, MEDIA_TYPE, parse } from "@sdxc/json-patch";
import { isFailure } from "@sdxc/result";
const STATUS = { "invalid-patch": 400, unresolvable: 422, "test-failed": 409 } as const;
async function updateArticle(request: Request, id: string) {
if (request.headers.get("Content-Type")?.split(";")[0]?.trim() !== MEDIA_TYPE) {
return new Response(null, { status: 415, headers: { "Accept-Patch": MEDIA_TYPE } });
}
let patch = parse(await request.text());
if (isFailure(patch)) return new Response(patch.error.message, { status: 400 });
let next = apply(await articles.find(id), patch.data);
if (isFailure(next)) {
return new Response(next.error.message, { status: STATUS[next.error.reason] });
}
await articles.save(id, next.data);
return Response.json(next.data);
}Pattern: Update Only If Nobody Else Did
A test operation turns a patch into a compare-and-set: the update applies only while the
member still holds the value the client last saw.
import { MEDIA_TYPE, stringify } from "@sdxc/json-patch";
let response = await fetch(`/articles/${id}`, {
method: "PATCH",
headers: { "Content-Type": MEDIA_TYPE },
body: stringify([
{ op: "test", path: "/revision", value: 7 },
{ op: "replace", path: "/revision", value: 8 },
{ op: "remove", path: "/tags/2" },
]),
});
if (response.status === 409) {
/** Someone else saved first: reload and retry. */
}Pattern: Send Only What Changed
import { diff, MEDIA_TYPE, stringify } from "@sdxc/json-patch";
let patch = diff(original, edited);
if (patch.length > 0) {
await fetch(`/articles/${id}`, {
method: "PATCH",
headers: { "Content-Type": MEDIA_TYPE },
body: stringify(patch),
});
}