Accept JSON Patch updates
Take RFC 6902 patches on a PATCH endpoint, answer each failure with the right status, and send guarded patches computed with diff.
Last updated
After reading this guide, you will know
- How to apply an
application/json-patch+jsonbody to a resource, all or nothing - How to answer a malformed, unresolvable or failed-test patch with 400, 422 or 409
- When to send JSON Patch instead of a merge patch, and how
diffandtestguard an edit
A merge patch cannot set a member to null, edit the middle of an array, or make a change
depend on what the resource holds now. A JSON Patch can: it is a list of operations (add,
remove, replace, move, copy, test), each aimed at a JSON Pointer. This guide adds a
JSON Patch update to the books API from
Build a JSON API with problem details with
@sdxc/json-patch, and a client that sends only what changed, guarded so it
never overwrites an edit it did not see.
npm add @sdxc/http @sdxc/json-patch @sdxc/json-pointer @sdxc/problem @sdxc/result \
@sdxc/validate remixAdd the patch failures to the problem catalog
A patch fails in three ways, each with its own status: a malformed document is a 400, a
path with nothing to act on is a 422, and a failed test is a 409, which tells a client
to reload and try again. Add an entry for each to the catalog:
import { defineProblems, ISSUES_SCHEMA } from "@sdxc/problem";
import * as s from "remix/data-schema";
export default defineProblems("https://books.example.com/docs/errors/", {
badRequest: {
slug: "bad-request",
status: 400,
title: "The request is malformed",
},
notFound: {
slug: "not-found",
status: 404,
title: "The resource does not exist",
},
validationFailed: {
slug: "validation-failed",
status: 422,
title: "The request failed validation",
extensions: s.object({ errors: ISSUES_SCHEMA }),
},
rateLimited: { slug: "rate-limited", status: 429, title: "Too many requests" },
internal: {
slug: "internal",
status: 500,
title: "The request failed on the server",
},
unsupportedMediaType: {
slug: "unsupported-media-type",
status: 415,
title: "The request body's media type is not accepted",
},
patchUnresolvable: {
slug: "patch-unresolvable",
status: 422,
title: "The patch names a location the resource does not have",
},
patchConflict: {
slug: "patch-conflict",
status: 409,
title: "The resource no longer matches the patch's test",
},
});Map JsonPatchError's reason onto those builders in one function:
import type { JsonPatchError } from "@sdxc/json-patch";
import problems from "~/config/problems";
export function jsonPatchProblem(error: JsonPatchError): Response {
let detail = error.message;
switch (error.reason) {
case "invalid-patch":
return problems.badRequest({ detail });
case "unresolvable":
return problems.patchUnresolvable({ detail });
case "test-failed":
return problems.patchConflict({ detail });
}
}Apply the patch in the update action
Add update: patch("/api/books/:bookId") to routes/api.ts. The action checks the media type,
applies the patch to the book's writable members, and validates the result with the create
schema, so a patch can only produce a book a create would accept:
import { ok } from "@sdxc/http/response/json";
import { apply, MEDIA_TYPE, parse } from "@sdxc/json-patch";
import { issuesFrom } from "@sdxc/problem";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import { createAction } from "remix/router";
import Book, { serializeBook } from "~/app/models/book";
import { jsonPatchProblem } from "~/app/http/controllers/api/books/json-patch-problem";
import { writableBook } from "~/app/http/controllers/api/books/writable-book";
import { BOOK_INPUT } from "~/app/http/validators/books";
import problems from "~/config/problems";
import routes from "~/routes/api";
export default createAction(routes.api.books.update, async (ctx) => {
let book = await Book.find(ctx.db, ctx.params.bookId);
if (book === null) return problems.notFound({ detail: "No book has that id." });
let type = ctx.request.headers.get("Content-Type")?.split(";")[0]?.trim();
if (type?.toLowerCase() !== MEDIA_TYPE) {
let headers = { "Accept-Patch": MEDIA_TYPE };
return problems.unsupportedMediaType({}, { headers });
}
let patch = parse(await ctx.request.text());
if (isFailure(patch)) return jsonPatchProblem(patch.error);
let next = apply(writableBook(book), patch.data);
if (isFailure(next)) return jsonPatchProblem(next.error);
let input = await validate(next.data, BOOK_INPUT);
if (isFailure(input)) {
let errors = issuesFrom(input.error);
return problems.validationFailed({ extensions: { errors } });
}
let saved = await Book.update(ctx.db, book.id, input.data);
return ok({ book: serializeBook(saved) });
});writableBook is the function from
Idempotent writes and JSON Merge Patch. apply runs the
operations against a private copy, so when one fails none take effect.
Send only what changed, guarded by test
A client holding the book it read and the edited copy computes the patch with diff.
Prefixing each replace and remove with a test of the value it overwrites makes the patch
a compare-and-set:
import type { JSONObject, JSONValue, Operation } from "@sdxc/json-patch";
import { diff, MEDIA_TYPE, stringify } from "@sdxc/json-patch";
import { get } from "@sdxc/json-pointer";
import { isSuccess } from "@sdxc/result";
export function guardedPatch(original: JSONObject, edited: JSONObject): Operation[] {
let patch: Operation[] = [];
for (let operation of diff(original, edited)) {
if (operation.op === "replace" || operation.op === "remove") {
let current = get(original, operation.path);
if (isSuccess(current)) {
patch.push({
op: "test",
path: operation.path,
value: current.data as JSONValue,
});
}
}
patch.push(operation);
}
return patch;
}
export async function saveBook(id: string, original: JSONObject, edited: JSONObject) {
let patch = guardedPatch(original, edited);
if (patch.length === 0) return null;
return await fetch(`https://books.example.com/api/books/${id}`, {
method: "PATCH",
headers: { "Content-Type": MEDIA_TYPE },
body: stringify(patch),
});
}Editing the year and clearing the summary sends four operations:
[
{ "op": "test", "path": "/summary", "value": "A writer is pulled back in time." },
{ "op": "remove", "path": "/summary" },
{ "op": "test", "path": "/year", "value": 1979 },
{ "op": "replace", "path": "/year", "value": 1980 }
]A 409 means another client changed one of those members: read the book again and reapply
the edit. A member nobody else touched never conflicts, unlike an If-Match check on the whole
resource.
Test the patch rules
The rules are pure, so a test applies patches to a book built by hand:
import type { BookRow } from "~/app/models/book";
import { apply } from "@sdxc/json-patch";
import { isFailure } from "@sdxc/result";
import { expect, test } from "vitest";
import { jsonPatchProblem } from "~/app/http/controllers/api/books/json-patch-problem";
import { writableBook } from "~/app/http/controllers/api/books/writable-book";
let book = {
id: "01j9z4k2m8q7r6t5v4w3x2y1z0",
title: "Kindred",
author: "Octavia E. Butler",
year: 1979,
summary: "A writer is pulled back in time.",
} as BookRow;
test("a failed test answers 409 and changes nothing", () => {
let target = writableBook(book);
let next = apply(target, [
{ op: "replace", path: "/year", value: 1980 },
{ op: "test", path: "/title", value: "Dawn" },
]);
if (!isFailure(next)) expect.unreachable("the title is Kindred");
expect(next.error.index).toBe(1);
expect(jsonPatchProblem(next.error).status).toBe(409);
expect(target.year).toBe(1979);
});
test("removing a member the book lacks answers 422", () => {
let next = apply(writableBook(book), [{ op: "remove", path: "/isbn" }]);
if (!isFailure(next)) expect.unreachable("a book has no isbn");
expect(next.error.reason).toBe("unresolvable");
expect(jsonPatchProblem(next.error).status).toBe(422);
});Run it from the terminal
Build the patch a client would send for an edit, and try it on a copy of the book, from two JSON files:
sdxc pkg json-patch diff book.json edited.json > patch.json
sdxc pkg json-patch validate patch.json
sdxc pkg json-patch apply patch.json book.jsonapply exits 1 when an operation's path does not resolve or a test fails, the same cases the
endpoint answers with 422 and 409. Use a package from the terminal covers what every package command line shares.
Where to go next
Idempotent writes and JSON Merge Patch — the simpler partial update, and the
ETagcheck that guards a whole resource.Query JSON with JSONPath — find the nodes a patch should touch, and turn their locations into pointers.
@sdxc/json-patch— every operation type and howdiffedits arrays.