Skip to content

PATCH Semantics

You need PATCH endpoints that distinguish between RFC 7386 JSON Merge Patch (partial object merge) and RFC 6902 JSON Patch (operations array), with proper content-type enforcement and validation schemas for partial updates.

A merge-patch request sends a partial JSON object. Properties present in the patch overwrite the target; properties set to null are removed.

import { compose, body, validate } from "@centralping/ergo";
const pipeline = compose(
{fn: body(), setPath: "body"},
validate({
body: {
type: "object",
minProperties: 1,
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
bio: { type: ["string", "null"] },
},
additionalProperties: false,
},
}),
async (req, res, acc) => {
const updated = await db.merge(acc.route.params.id, acc.body.parsed);
return { response: { body: updated } };
},
);

A JSON Patch request sends an array of operations (add, remove, replace, move, copy, test). Each operation targets a specific path in the document.

import { compose, body, validate } from "@centralping/ergo";
const pipeline = compose(
{fn: body(), setPath: "body"},
validate({
body: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["op", "path"],
properties: {
op: { type: "string", enum: ["add", "remove", "replace", "move", "copy", "test"] },
path: { type: "string", pattern: "^/" },
value: {},
from: { type: "string", pattern: "^/" },
},
additionalProperties: false,
},
},
}),
async (req, res, acc) => {
const updated = await db.applyPatch(acc.route.params.id, acc.body.parsed);
return { response: { body: updated } };
},
);

When a single endpoint needs to handle both merge-patch and JSON Patch, inspect acc.body.type to determine which format was sent:

import { compose, body } from "@centralping/ergo";
const pipeline = compose(
{fn: body(), setPath: "body"},
async (req, res, acc) => {
const { type, parsed } = acc.body;
const id = acc.route.params.id;
if (type === "application/json-patch+json") {
const updated = await db.applyPatch(id, parsed);
return { response: { body: updated } };
}
// Default: merge-patch semantics for application/json
// and application/merge-patch+json
const updated = await db.merge(id, parsed);
return { response: { body: updated } };
},
);

ergo-router enforces valid PATCH content types at the transport level before the request reaches your route pipeline. This is enabled by default and configured at router creation time (not per-route):

import createRouter from "@centralping/ergo-router";
// strictPatch defaults to true — explicit here for clarity
const router = createRouter({ strictPatch: true });

When a PATCH request arrives with a content type not in the allowed set, the router immediately responds with 415 Unsupported Media Type and an Accept-Patch header listing the valid types.

Content-TypeRFCSemantics
application/jsonGeneric JSON (treated as merge-patch by convention)
application/merge-patch+jsonRFC 7386Explicit merge-patch semantics
application/json-patch+jsonRFC 6902Operations array

All three types are parsed as JSON by the body middleware (included in the default types array). The distinction is semantic — the parsed result is always a JavaScript object or array.

Validation Schema Design for Partial Updates

Section titled “Validation Schema Design for Partial Updates”

Merge-patch schemas differ from creation schemas:

ConcernCreate (POST)Partial Update (PATCH)
requiredList mandatory fieldsOmit entirely — any subset is valid
minPropertiesOptional1 — reject empty patches
null valuesTypically disallowedAllow for field removal (RFC 7386 §2)
additionalPropertiesfalsefalse — reject unknown fields

For APIs that accept custom PATCH content types or use PATCH for non-standard semantics, disable enforcement at router creation:

const router = createRouter({ strictPatch: false });

This removes the transport-level content-type check. Your route pipeline is then responsible for validating the incoming content type.