json1-8 Exercise 3: Patch, Merge Patch and PATCH over HTTP =========================================================== Run with Node 24.18.0 (json-p3 3.0.1, fast-json-patch 3.1.1, jsonpath-plus 11.2.0, json-merge-patch 1.0.2), Python 3.14.0 (jsonpointer 3.2.1, jsonpatch 1.35, python-jsonpath 2.2.1, jsonpath-ng 1.10.1, json-merge-patch 0.3.0) and jq 1.8.2, all installed in a scratch folder. Order: node pa1.mjs (writes patch_node.json), node pa2.mjs, python pa1.py, node mp1.mjs (writes mp_node.json), python mp1.py, python mpjq.py, bash jqstar.sh, bash patch_demo.sh. --- patch_cases.json (RFC 6902 appendix A.1 to A.16 except A.13, plus nine of my own; each has the expected result or ERROR) --- [ ["A.1 add an object member", {"foo":"bar"}, [{"op":"add","path":"/baz","value":"qux"}], {"baz":"qux","foo":"bar"}], ["A.2 add an array element", {"foo":["bar","baz"]}, [{"op":"add","path":"/foo/1","value":"qux"}], {"foo":["bar","qux","baz"]}], ["A.3 remove an object member", {"baz":"qux","foo":"bar"}, [{"op":"remove","path":"/baz"}], {"foo":"bar"}], ["A.4 remove an array element", {"foo":["bar","qux","baz"]}, [{"op":"remove","path":"/foo/1"}], {"foo":["bar","baz"]}], ["A.5 replace a value", {"baz":"qux","foo":"bar"}, [{"op":"replace","path":"/baz","value":"boo"}], {"baz":"boo","foo":"bar"}], ["A.6 move a value", {"foo":{"bar":"baz","waldo":"fred"},"qux":{"corge":"grault"}}, [{"op":"move","from":"/foo/waldo","path":"/qux/thud"}], {"foo":{"bar":"baz"},"qux":{"corge":"grault","thud":"fred"}}], ["A.7 move an array element", {"foo":["all","grass","cows","eat"]}, [{"op":"move","from":"/foo/1","path":"/foo/3"}], {"foo":["all","cows","eat","grass"]}], ["A.8 test (success)", {"baz":"qux","foo":["a",2,"c"]}, [{"op":"test","path":"/baz","value":"qux"},{"op":"test","path":"/foo/1","value":2}], {"baz":"qux","foo":["a",2,"c"]}], ["A.9 test (failure)", {"baz":"qux"}, [{"op":"test","path":"/baz","value":"bar"}], "ERROR"], ["A.10 add a nested member object", {"foo":"bar"}, [{"op":"add","path":"/child","value":{"grandchild":{}}}], {"foo":"bar","child":{"grandchild":{}}}], ["A.11 ignore unrecognized elements",{"foo":"bar"}, [{"op":"add","path":"/baz","value":"qux","xyz":123}], {"foo":"bar","baz":"qux"}], ["A.12 add to a nonexistent target", {"foo":"bar"}, [{"op":"add","path":"/baz/bat","value":"qux"}], "ERROR"], ["A.14 ~ escape ordering", {"/":9,"~1":10}, [{"op":"test","path":"/~01","value":10}], {"/":9,"~1":10}], ["A.15 test: string vs number", {"/":9,"~1":10}, [{"op":"test","path":"/~01","value":"10"}], "ERROR"], ["A.16 add an array as one value", {"foo":["bar"]}, [{"op":"add","path":"/foo/-","value":["abc","def"]}], {"foo":["bar",["abc","def"]]}], ["copy a value", {"a":{"b":1}}, [{"op":"copy","from":"/a","path":"/c"}], {"a":{"b":1},"c":{"b":1}}], ["add past the end of an array", {"foo":["a"]}, [{"op":"add","path":"/foo/5","value":"x"}], "ERROR"], ["remove a missing member", {"foo":1}, [{"op":"remove","path":"/nope"}], "ERROR"], ["replace a missing member", {"foo":1}, [{"op":"replace","path":"/nope","value":2}], "ERROR"], ["add replaces an existing member", {"foo":1}, [{"op":"add","path":"/foo","value":2}], {"foo":2}], ["move into its own child", {"a":{"b":1}}, [{"op":"move","from":"/a","path":"/a/c"}], "ERROR"], ["test 1 against 1.0", {"n":1}, [{"op":"test","path":"/n","value":1.0}], {"n":1}], ["unknown op", {"a":1}, [{"op":"merge","path":"/a","value":2}], "ERROR"], ["empty path = replace the whole", {"a":1}, [{"op":"replace","path":"","value":{"z":9}}], {"z":9}] ] --- pa1.mjs (json-p3, and fast-json-patch with validation switched on) --- import fs from 'node:fs'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const fjp = require('fast-json-patch'); const jp3 = await import('json-p3'); const cases = JSON.parse(fs.readFileSync('patch_cases.json', 'utf8')); const canon = v => JSON.stringify(v, (k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x); const clone = o => JSON.parse(JSON.stringify(o)); const out = { 'fast-json-patch': {}, 'json-p3': {} }; for (const [label, doc, patch, expected] of cases) { let a, b; try { const errs = fjp.validate(patch, clone(doc)); if (errs) throw errs; a = canon(fjp.applyPatch(clone(doc), clone(patch), true, false).newDocument); } catch (e) { a = 'ERROR'; } try { b = canon(jp3.jsonpatch.apply(clone(patch), clone(doc))); } catch (e) { b = 'ERROR'; } out['fast-json-patch'][label] = a; out['json-p3'][label] = b; const exp = expected === 'ERROR' ? 'ERROR' : canon(expected); console.log(label.padEnd(36) + 'expected ' + (exp === 'ERROR' ? 'ERROR' : 'result').padEnd(9) + 'fast-json-patch: ' + (a === exp ? 'ok' : 'DIFFERS ' + a.slice(0, 40)).padEnd(14) + ' json-p3: ' + (b === exp ? 'ok' : 'DIFFERS ' + b.slice(0, 40))); } fs.writeFileSync('patch_node.json', JSON.stringify(out)); Output: A.1 add an object member expected result fast-json-patch: ok json-p3: ok A.2 add an array element expected result fast-json-patch: ok json-p3: ok A.3 remove an object member expected result fast-json-patch: ok json-p3: ok A.4 remove an array element expected result fast-json-patch: ok json-p3: ok A.5 replace a value expected result fast-json-patch: ok json-p3: ok A.6 move a value expected result fast-json-patch: ok json-p3: ok A.7 move an array element expected result fast-json-patch: ok json-p3: ok A.8 test (success) expected result fast-json-patch: ok json-p3: ok A.9 test (failure) expected ERROR fast-json-patch: ok json-p3: ok A.10 add a nested member object expected result fast-json-patch: ok json-p3: ok A.11 ignore unrecognized elements expected result fast-json-patch: ok json-p3: ok A.12 add to a nonexistent target expected ERROR fast-json-patch: ok json-p3: ok A.14 ~ escape ordering expected result fast-json-patch: ok json-p3: ok A.15 test: string vs number expected ERROR fast-json-patch: ok json-p3: ok A.16 add an array as one value expected result fast-json-patch: ok json-p3: ok copy a value expected result fast-json-patch: ok json-p3: ok add past the end of an array expected ERROR fast-json-patch: ok json-p3: ok remove a missing member expected ERROR fast-json-patch: ok json-p3: ok replace a missing member expected ERROR fast-json-patch: ok json-p3: ok add replaces an existing member expected result fast-json-patch: ok json-p3: ok move into its own child expected ERROR fast-json-patch: ok json-p3: ok test 1 against 1.0 expected result fast-json-patch: ok json-p3: ok unknown op expected ERROR fast-json-patch: ok json-p3: ok empty path = replace the whole expected result fast-json-patch: ok json-p3: ok --- pa2.mjs (fast-json-patch default versus validated; what a failing patch leaves behind) --- import fs from 'node:fs'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const fjp = require('fast-json-patch'); const jp3 = await import('json-p3'); const cases = JSON.parse(fs.readFileSync('patch_cases.json', 'utf8')); const canon = v => JSON.stringify(v, (k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x); const clone = o => JSON.parse(JSON.stringify(o)); const modes = { 'applyPatch(doc, patch)': (d, p) => canon(fjp.applyPatch(clone(d), clone(p)).newDocument), 'applyPatch(..., true) validate': (d, p) => canon(fjp.applyPatch(clone(d), clone(p), true).newDocument), }; let bad = {}; for (const m of Object.keys(modes)) bad[m] = []; for (const [label, doc, patch, expected] of cases) { const exp = expected === 'ERROR' ? 'ERROR' : canon(expected); for (const [m, f] of Object.entries(modes)) { let r; try { r = f(doc, patch); } catch (e) { r = 'ERROR'; } if (r !== exp) bad[m].push(label + ' (got ' + r.slice(0, 30) + ')'); } } for (const [m, l] of Object.entries(bad)) console.log(m.padEnd(34) + (l.length ? l.length + ' case(s) differ from the RFC:\n ' + l.join('\n ') : 'all 24 as the RFC says')); console.log('\n--- does a failing patch change the document it was given? (3 ops, the 3rd fails)'); const patch = [{ op: 'add', path: '/b', value: 2 }, { op: 'replace', path: '/a', value: 99 }, { op: 'remove', path: '/nope' }]; let d1 = { a: 1 }; try { fjp.applyPatch(d1, clone(patch), true); } catch (e) { console.log('fast-json-patch threw: ' + e.name); } console.log(' fast-json-patch, mutating (default) original doc afterwards:', JSON.stringify(d1)); let d2 = { a: 1 }; try { fjp.applyPatch(d2, clone(patch), true, false); } catch (e) {} console.log(' fast-json-patch, mutateDocument=false original doc afterwards:', JSON.stringify(d2)); let d3 = { a: 1 }; try { jp3.jsonpatch.apply(clone(patch), d3); } catch (e) { console.log('json-p3 threw: ' + e.constructor.name); } console.log(' json-p3 original doc afterwards:', JSON.stringify(d3)); Output: applyPatch(doc, patch) 3 case(s) differ from the RFC: add past the end of an array (got {"foo":["a","x"]}) remove a missing member (got {"foo":1}) replace a missing member (got {"foo":1,"nope":2}) applyPatch(..., true) validate all 24 as the RFC says --- does a failing patch change the document it was given? (3 ops, the 3rd fails) fast-json-patch threw: OPERATION_PATH_UNRESOLVABLE fast-json-patch, mutating (default) original doc afterwards: {"a":99,"b":2} fast-json-patch, mutateDocument=false original doc afterwards: {"a":1} json-p3 threw: JSONPatchError json-p3 original doc afterwards: {"a":99,"b":2} --- pa1.py (Python jsonpatch; compares all three libraries; atomicity; make_patch) --- import json, copy, jsonpatch cases = json.load(open('patch_cases.json', encoding='utf-8')) canon = lambda v: json.dumps(v, sort_keys=True, separators=(',', ':')) node = json.load(open('patch_node.json', encoding='utf-8')) bad = []; mine = {} for label, doc, patch, expected in cases: try: r = canon(jsonpatch.apply_patch(copy.deepcopy(doc), copy.deepcopy(patch))) except Exception as e: r = 'ERROR' mine[label] = r exp = 'ERROR' if expected == 'ERROR' else canon(expected) if r != exp: bad.append(label + ' (got ' + r[:30] + ')') print('jsonpatch %s: %s' % (jsonpatch.__version__, ('%d case(s) differ from the RFC: ' % len(bad)) + '; '.join(bad) if bad else 'all %d as the RFC says' % len(cases))) agree = [l for l in mine if mine[l] == node['json-p3'][l] == node['fast-json-patch'][l]] print('the three libraries (fast-json-patch with validation, json-p3, jsonpatch) agree on %d of %d cases' % (len(agree), len(cases))) # the test case "1 vs 1.0" and atomicity patch = [{'op': 'add', 'path': '/b', 'value': 2}, {'op': 'replace', 'path': '/a', 'value': 99}, {'op': 'remove', 'path': '/nope'}] d = {'a': 1} try: jsonpatch.apply_patch(d, patch) except Exception as e: print('jsonpatch threw', type(e).__name__) print(' default (in_place=False) original doc afterwards:', json.dumps(d)) d = {'a': 1} try: jsonpatch.apply_patch(d, patch, in_place=True) except Exception as e: pass print(' in_place=True original doc afterwards:', json.dumps(d)) # make_patch: diff two documents a = {'a': 1, 'b': [1, 2, 3], 'c': {'d': 4}}; b = {'a': 2, 'b': [1, 3], 'c': {'d': 4, 'e': 5}} p = jsonpatch.make_patch(a, b); print('make_patch ->', p.to_string()) print('apply it back gives b:', jsonpatch.apply_patch(a, p) == b) Output: jsonpatch 1.35: all 24 as the RFC says the three libraries (fast-json-patch with validation, json-p3, jsonpatch) agree on 24 of 24 cases jsonpatch threw JsonPatchConflict default (in_place=False) original doc afterwards: {"a": 1} in_place=True original doc afterwards: {"a": 99, "b": 2} make_patch -> [{"op": "replace", "path": "/a", "value": 2}, {"op": "remove", "path": "/b/1"}, {"op": "add", "path": "/c/e", "value": 5}] apply it back gives b: True --- mp_cases.json (RFC 7396 appendix A, 15 rows, plus three of my own) --- [ [{"a":"b","c":{"d":"e","f":"g"}}, {"a":"z","c":{"f":null}}, {"a":"z","c":{"d":"e"}}], [{"a":"b"}, {"a":"c"}, {"a":"c"}], [{"a":"b"}, {"b":"c"}, {"a":"b","b":"c"}], [{"a":"b"}, {"a":null}, {}], [{"a":"b","b":"c"}, {"a":null}, {"b":"c"}], [{"a":["b"]}, {"a":"c"}, {"a":"c"}], [{"a":"c"}, {"a":["b"]}, {"a":["b"]}], [{"a":{"b":"c"}}, {"a":{"b":"d","c":null}}, {"a":{"b":"d"}}], [{"a":[{"b":"c"}]}, {"a":[1]}, {"a":[1]}], [["a","b"], ["c","d"], ["c","d"]], [{"a":"b"}, ["c"], ["c"]], [{"a":"foo"}, null, null], [{"a":"foo"}, "bar", "bar"], [{"e":null}, {"a":1}, {"e":null,"a":1}], [[1,2], {"a":"b","c":null}, {"a":"b"}], [{}, {"a":{"bb":{"ccc":null}}}, {"a":{"bb":{}}}], [{"a":1}, {"a":null}, {}], [{"tags":["x","y"]}, {"tags":["x","y","z"]}, {"tags":["x","y","z"]}] ] --- mp1.mjs (Node merge patch, and the null round trip) --- import fs from 'node:fs'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const mp = require('json-merge-patch'); const jp3 = await import('json-p3'); console.log('json-merge-patch exports:', Object.keys(mp).join(' '), '| json-p3 merge?', Object.keys(jp3).filter(k => /merge/i.test(k)).join(' ') || 'none'); const cases = JSON.parse(fs.readFileSync('mp_cases.json', 'utf8')); const clone = o => JSON.parse(JSON.stringify(o)); const canon = v => JSON.stringify(v, (k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort()) : x); let ok = 0; const out = []; for (const [target, patch, expected] of cases) { const r = canon(mp.apply(clone(target), clone(patch))); out.push(r); const good = r === canon(expected); if (good) ok++; console.log((good ? 'ok ' : 'DIFFERS') + ' ' + JSON.stringify(target).padEnd(34) + ' + ' + JSON.stringify(patch).padEnd(30) + ' -> ' + r); } console.log(ok + ' of ' + cases.length + ' as RFC 7396 says'); fs.writeFileSync('mp_node.json', JSON.stringify(out)); console.log('\ngenerate(a, b):', JSON.stringify(mp.generate({ a: 1, b: { c: 2, d: 3 }, e: [1, 2] }, { a: 1, b: { c: 5 }, e: [1, 2, 3], f: null }))); console.log('can a merge patch set a value to null? {"a":1} with the wish a=null ->', JSON.stringify(mp.apply({ a: 1 }, { a: null })), '(the key is removed)'); console.log('apply() mutates its target?', (() => { const t = { a: 1 }; mp.apply(t, { a: 2 }); return JSON.stringify(t); })()); Output: json-merge-patch exports: apply generate merge | json-p3 merge? none ok {"a":"b","c":{"d":"e","f":"g"}} + {"a":"z","c":{"f":null}} -> {"a":"z","c":{"d":"e"}} ok {"a":"b"} + {"a":"c"} -> {"a":"c"} ok {"a":"b"} + {"b":"c"} -> {"a":"b","b":"c"} ok {"a":"b"} + {"a":null} -> {} ok {"a":"b","b":"c"} + {"a":null} -> {"b":"c"} ok {"a":["b"]} + {"a":"c"} -> {"a":"c"} ok {"a":"c"} + {"a":["b"]} -> {"a":["b"]} ok {"a":{"b":"c"}} + {"a":{"b":"d","c":null}} -> {"a":{"b":"d"}} ok {"a":[{"b":"c"}]} + {"a":[1]} -> {"a":[1]} ok ["a","b"] + ["c","d"] -> ["c","d"] ok {"a":"b"} + ["c"] -> ["c"] ok {"a":"foo"} + null -> null ok {"a":"foo"} + "bar" -> "bar" ok {"e":null} + {"a":1} -> {"a":1,"e":null} ok [1,2] + {"a":"b","c":null} -> {"a":"b"} ok {} + {"a":{"bb":{"ccc":null}}} -> {"a":{"bb":{}}} ok {"a":1} + {"a":null} -> {} ok {"tags":["x","y"]} + {"tags":["x","y","z"]} -> {"tags":["x","y","z"]} 18 of 18 as RFC 7396 says generate(a, b): {"f":null,"b":{"c":5,"d":null},"e":[1,2,3]} can a merge patch set a value to null? {"a":1} with the wish a=null -> {} (the key is removed) apply() mutates its target? {"a":2} --- mp1.py (Python merge patch) --- import json, copy, json_merge_patch as m cases = json.load(open('mp_cases.json', encoding='utf-8')) canon = lambda v: json.dumps(v, sort_keys=True, separators=(',', ':')) node = json.load(open('mp_node.json', encoding='utf-8')) ok = 0; same = 0 for i, (t, p, e) in enumerate(cases): r = canon(m.merge(copy.deepcopy(t), copy.deepcopy(p))) ok += r == canon(e); same += r == node[i] print('json-merge-patch (Python) 0.3.0: %d of %d as RFC 7396 says; agrees with the Node library on %d of %d' % (ok, len(cases), same, len(cases))) print('create_patch:', json.dumps(m.create_patch({'a': 1, 'b': {'c': 2, 'd': 3}, 'e': [1, 2]}, {'a': 1, 'b': {'c': 5}, 'e': [1, 2, 3], 'f': None}))) t = {'a': 1}; m.merge(t, {'a': 2}); print('merge() mutates its target?', json.dumps(t)) Output: json-merge-patch (Python) 0.3.0: 18 of 18 as RFC 7396 says; agrees with the Node library on 18 of 18 create_patch: {"b": {"d": null, "c": 5}, "e": [1, 2, 3], "f": null} merge() mutates its target? {"a": 2} --- mp.jq (merge patch as a jq function, from the RFC's pseudo-code) --- def merge_patch($patch): if ($patch | type) == "object" then (if type == "object" then . else {} end) as $t | reduce ($patch | to_entries[]) as $e ($t; if $e.value == null then del(.[$e.key]) else .[$e.key] = ((.[$e.key] // null) | merge_patch($e.value)) end) else $patch end; --- mpjq.py (runs mp.jq on all 18 cases) --- # Run the jq merge_patch function (mp.jq) on every case in mp_cases.json and compare with the expected result import json, subprocess cases = json.load(open('mp_cases.json', encoding='utf-8')) ok = 0 for t, p, e in cases: prog = 'include "mp"; ' + json.dumps(t) + ' | merge_patch(' + json.dumps(p) + ')' r = subprocess.run([r'C:\lwdj\json\c4\bin\jq.exe', '-S', '-c', '-n', '-L', '.', prog], capture_output=True, text=True, encoding='utf-8') got = r.stdout.strip(); want = json.dumps(e, sort_keys=True, separators=(',', ':')) ok += got == want if got != want: print('DIFF', t, p, 'got', got, r.stderr.strip()[:80], 'want', want) print('jq merge_patch: %d of %d cases as RFC 7396 says' % (ok, len(cases))) Output: jq merge_patch: 18 of 18 cases as RFC 7396 says --- jqstar.sh (jq's * and + operators on merge-patch inputs) --- export PATH="/c/lwdj/json/c4/bin:$PATH" jq -nc '[{"a":"b"} * {"a":null}, ({"a":"b","c":{"d":"e","f":"g"}} * {"a":"z","c":{"f":null}}), ({"a":["b"]} * {"a":"c"}), ({"a":{"x":1}} + {"a":{"y":2}})]' Output: [{"a":null},{"a":"z","c":{"d":"e","f":null}},{"a":"c"},{"a":{"y":2}}] --- patchsrv.mjs (a small server made for this exercise; the status codes are my choices) --- // A tiny server for the exercise: one resource, PATCH accepts two media types import http from 'node:http'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const fjp = require('fast-json-patch'); const mp = require('json-merge-patch'); let doc = { name: 'Ada', email: 'ada@example.com', tags: ['a', 'b'], address: { city: 'London', zip: 'N1' } }; const send = (res, code, body, type = 'application/json') => { res.writeHead(code, { 'content-type': type }); res.end(JSON.stringify(body)); }; http.createServer((req, res) => { if (req.method === 'GET') return send(res, 200, doc); if (req.method !== 'PATCH') return send(res, 405, { error: 'use GET or PATCH' }); let body = ''; req.on('data', c => body += c); req.on('end', () => { const type = (req.headers['content-type'] || '').split(';')[0].trim(); let patch; try { patch = JSON.parse(body); } catch (e) { return send(res, 400, { error: 'body is not JSON' }); } try { if (type === 'application/json-patch+json') { if (!Array.isArray(patch)) return send(res, 422, { error: 'a JSON Patch document must be an array' }); doc = fjp.applyPatch(JSON.parse(JSON.stringify(doc)), patch, true, false).newDocument; // validate operations, do not mutate until it succeeds } else if (type === 'application/merge-patch+json') { doc = mp.apply(JSON.parse(JSON.stringify(doc)), patch); } else return send(res, 415, { error: 'unsupported media type', accepted: ['application/json-patch+json', 'application/merge-patch+json'] }); send(res, 200, doc); } catch (e) { send(res, 409, { error: 'patch could not be applied', detail: e.name || String(e) }); } }); }).listen(8766, '127.0.0.1'); --- patch_demo.sh (curl against that server) --- # starts patchsrv.mjs, sends seven PATCH requests with curl, stops the server node patchsrv.mjs & SP=$!; sleep 1 U=http://127.0.0.1:8766 req() { echo "\$ curl -s -X PATCH -H 'Content-Type: $1' -d '$2'"; curl -s -w ' [HTTP %{http_code}]\n' -X PATCH -H "Content-Type: $1" -d "$2" $U; } echo "start:"; curl -s $U; echo req application/merge-patch+json '{"email":null,"address":{"zip":"N2"},"tags":["a","b","c"]}' req application/json-patch+json '[{"op":"test","path":"/name","value":"Ada"},{"op":"replace","path":"/address/city","value":"Leeds"},{"op":"add","path":"/tags/-","value":"d"}]' req application/json-patch+json '[{"op":"replace","path":"/address/city","value":"Hull"},{"op":"remove","path":"/nope"}]' echo "state after the failed patch:"; curl -s $U; echo req application/json-patch+json '{"op":"add","path":"/x","value":1}' req application/json '{"name":"B"}' req application/merge-patch+json '{not json' kill $SP 2>/dev/null; wait $SP 2>/dev/null Output: start: {"name":"Ada","email":"ada@example.com","tags":["a","b"],"address":{"city":"London","zip":"N1"}} $ curl -s -X PATCH -H 'Content-Type: application/merge-patch+json' -d '{"email":null,"address":{"zip":"N2"},"tags":["a","b","c"]}' {"name":"Ada","tags":["a","b","c"],"address":{"city":"London","zip":"N2"}} [HTTP 200] $ curl -s -X PATCH -H 'Content-Type: application/json-patch+json' -d '[{"op":"test","path":"/name","value":"Ada"},{"op":"replace","path":"/address/city","value":"Leeds"},{"op":"add","path":"/tags/-","value":"d"}]' {"name":"Ada","tags":["a","b","c","d"],"address":{"city":"Leeds","zip":"N2"}} [HTTP 200] $ curl -s -X PATCH -H 'Content-Type: application/json-patch+json' -d '[{"op":"replace","path":"/address/city","value":"Hull"},{"op":"remove","path":"/nope"}]' {"error":"patch could not be applied","detail":"OPERATION_PATH_UNRESOLVABLE"} [HTTP 409] state after the failed patch: {"name":"Ada","tags":["a","b","c","d"],"address":{"city":"Leeds","zip":"N2"}} $ curl -s -X PATCH -H 'Content-Type: application/json-patch+json' -d '{"op":"add","path":"/x","value":1}' {"error":"a JSON Patch document must be an array"} [HTTP 422] $ curl -s -X PATCH -H 'Content-Type: application/json' -d '{"name":"B"}' {"error":"unsupported media type","accepted":["application/json-patch+json","application/merge-patch+json"]} [HTTP 415] $ curl -s -X PATCH -H 'Content-Type: application/merge-patch+json' -d '{not json' {"error":"body is not JSON"} [HTTP 400] WHY THIS WORKS AS AN ANSWER --------------------------- Every case has an expected result taken from the RFC (or, for my own nine, worked out first), and errors are expected as errors. All three patch libraries agree on all 24 cases when fast-json-patch validates operations; its default applyPatch accepts three invalid patches (add past the end, remove or replace a missing member). The atomicity check shows fast-json-patch (default), json-p3 and jsonpatch with in_place=True leave a half-applied document when the third operation fails, while fast-json-patch with mutateDocument=false and jsonpatch's default leave it untouched. The merge patch results agree across the Node library, the Python library and my jq function on 18 of 18; the null round trip shows the format cannot express 'set this to null'; jq's * operator keeps nulls and so differs from the RFC. The HTTP demo applies each patch to a copy, which is why the failing patch changes nothing (the city is still Leeds). Not tested: other libraries, very large patches, concurrent patches, and the HTTP status codes other servers would choose.