JSON Pointer, Patch et Merge Patch
L’essentiel : Trois courtes spécifications forment un protocole de modification : Pointer localise, Patch édite précisément, Merge Patch fusionne partiellement. Un mauvais choix nuit aux tableaux et à la concurrence.

L'essentiel : utilisez JSON Pointer pour localiser par position et JSONPath pour filtrer ou récurser ; confiez l'insertion/retrait dans un tableau, les écritures de null et les assertions de version à JSON Patch, et réservez Merge Patch aux configurations plates et aux ressources de type formulaire.
Localiser et modifier sont deux choses différentes, et les spécifications les répartissent en trois documents. Le prix habituel de leur confusion est « tout le tableau a été remplacé » sans que personne ne le remarque.
1. JSON Pointer : la syntaxe de localisation la plus courte
La RFC 6901 fait exactement une chose : pointer un nœud d'un document avec une chaîne.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
Trois règles :
- La chaîne vide pointe le nœud racine lui-même, pas un « nœud vide » ;
- Chaque
/sépare un jeton de référence ; les tableaux utilisent des indices décimaux (les zéros initiaux ne sont pas autorisés,01est donc invalide) ; - Dans un jeton,
~s'écrit~0et/s'écrit~1— remplacez~avant/, sinon vous finissez par doubler l'échappement.
/**
* Escapes a single reference token into a JSON Pointer segment.
* @param token Original key name or index
* @returns Escaped token
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* Joins an array of key names into a JSON Pointer.
* @param tokens Array of key names or indices
* @returns Pointer string beginning with /
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
La valeur se montre le mieux lorsque la clé elle-même contient / : le pointeur de {"a/b": 1} est /a~1b, pas /a/b (ce dernier signifie b sous a).
Utilisé dans une URL, l'encodage en pourcentage est également nécessaire, car # et ~ ont des significations particulières dans un fragment.
2. JSON Patch : une séquence atomique de six opérations
La RFC 6902 décrit les modifications avec un tableau d'opérations, de type de média application/json-patch+json :
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新的名字" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| Opération | Sémantique | Frontière |
|---|---|---|
add |
Ajoute un membre ou insère un élément de tableau | L'index de tableau peut égaler la longueur (ajout en fin) ; - signifie la fin |
remove |
Supprime | Le chemin doit exister |
replace |
Remplace une valeur existante | Le chemin doit exister (équivalent à remove + add, mais exige que le chemin existe d'abord) |
move |
Déplace | Vous ne pouvez pas déplacer un nœud sous son propre sous-chemin |
copy |
Copie | La source doit exister |
test |
Affirme une égalité | En cas d'échec, tout le patch n'est pas appliqué ; utilisé pour le verrouillage optimiste |
La propriété clé est l'atomicité : si une opération échoue, tout le patch est annulé, il n'y a donc pas d'état intermédiaire où « les trois premières ont été appliquées et la quatrième a échoué ».
Cela en fait un excellent corps de requête PATCH.
2.1 Utiliser test pour la concurrence optimiste
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Le client fait d'abord un GET de l'etag et l'écrit dans l'opération test. En cas d'écritures concurrentes, le test de la seconde requête échoue et le serveur renvoie
409 Conflict ou 412 Precondition Failed. C'est bien plus sûr que le dernier-écrit-gagne et cela ne nécessite aucun verrou distribué.
2.2 Les opérations sur les tableaux sont les plus sujettes aux erreurs
// Want to insert at index 1
{ "op": "add", "path": "/items/1", "value": {...} } // Correct: insert; the original index 1 and everything after shift back
// Want to replace index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Correct: replace
add à l'index de tableau n est une insertion, pas un écrasement ; un index hors limites (n > length) doit lever une erreur.
Certaines implémentations se montrent laxistes sur ce point, ce qui fait que le même patch se comporte différemment selon les serveurs.
3. Merge Patch : un fragment qui ressemble à la cible
La RFC 7386 ne compte que quatre règles :
- La cible est un objet et le patch est un objet → fusion récursive ;
- La valeur d'une clé dans le patch est
null→ supprimer cette clé de la cible ; - La valeur d'une clé dans le patch n'est pas un objet → remplacer directement ;
- Le patch lui-même n'est pas un objet → toute la cible est remplacée.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
Le type de média est application/merge-patch+json. Il se lit comme une « mise à jour partielle », ce qui le rend particulièrement facile à mal utiliser :
- Les tableaux ne peuvent être remplacés qu'en bloc. Supprimer
list[2]? Merge Patch ne sait pas le faire ; - Vous ne pouvez pas écrire
null.nullest déjà pris pour signifier la suppression ; - Il ne peut pas exprimer un troisième état entre « laisser ce champ tranquille » et « supprimer ce champ » (par exemple, le mettre explicitement à vide).
4. Matrice de capacités : lequel choisir
| Besoin | JSON Patch | Merge Patch |
|---|---|---|
| Remplacer précisément un élément de tableau donné | ✓ | ✗ |
| Supprimer un élément d'un tableau | ✓ | ✗ |
| Mettre à jour partiellement plusieurs champs | ✓ (plusieurs replace) | ✓ (le plus concis) |
Écrire une valeur null |
✓ | ✗ |
| Contrôle de concurrence (assertion de version) | ✓ (test) | ✗ (seulement via If-Match) |
| Lisibilité du patch | Moyenne | Bonne (ressemble aux données mêmes) |
| Complexité d'implémentation | Élevée | Faible |
| Formes de ressources adaptées | Profondément imbriquées, de type liste | Configuration plate, formulaires |
Règles empiriques :
- Formulaires, pages de paramètres, ressources de configuration → Merge Patch ; le frontend ne soumet que les champs modifiés ;
- Ajouts et retraits dans des listes, transitions d'état, tout ce qui nécessite une protection de concurrence → JSON Patch ;
- Les deux peuvent coexister : différents points de terminaison déclarent différents
Content-Type, et le serveur dispatche selon le type.
5. Notes d'implémentation
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// test operations are validated by default; setting mutateDocument to true modifies in place, use with caution
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
Quatre points facilement négligés :
- Ne mutez pas des objets partagés sur place ; un patch échoué laisse un état à moitié modifié (sauf si l'implémentation garantit l'annulation) ;
- Validation des chemins : les patchs venus de l'extérieur doivent valider chaque
path/fromet rejeter des clés comme__proto__etconstructor; - Limites de taille : plafonnez le nombre d'entrées du patch pour empêcher qu'un seul patch exécute des centaines de milliers d'opérations ;
- Audit : les patchs se prêtent naturellement aux journaux d'audit — ils sont une description complète de « ce qui a changé ».
6. Sémantique des erreurs
| Situation | Code de statut suggéré | Explication |
|---|---|---|
| Le patch n'est pas un tableau / est structurellement invalide | 400 Bad Request |
Le format du corps de requête est faux |
Le nœud référencé par path n'existe pas |
422 Unprocessable Entity |
Syntaxiquement valide mais sémantiquement inexécutable |
Une opération test a échoué |
409 ou 412 |
Indique un conflit de version |
| Pas de permission pour modifier ce chemin | 403 Forbidden |
Ne le masquez pas avec 404, sauf si vous voulez délibérément cacher l'existence de la ressource |
Distinguer 400 de 422 compte : le premier signifie « je ne comprends pas votre patch », le second « je le comprends, mais ces données n'autorisent pas cette modification ».
7. Liste de vérification
- [ ] Le point de terminaison déclare le bon
Content-Type(json-patch+json/merge-patch+json) ; - [ ] L'application du patch est atomique, et un échec ne laisse aucun résultat partiel ;
- [ ] Les patchs externes sont validés contre une liste blanche de chemins et une limite d'entrées ;
- [ ] Le contrôle de concurrence utilise
testouIf-Match; - [ ] Les patchs sont écrits dans le journal d'audit ;
- [ ] Les scénarios de tableaux choisissent explicitement JSON Patch ; ne comptez pas sur Merge Patch.
FAQ
Comment décider entre JSON Patch et Merge Patch ?
Utilisez JSON Patch lorsque vous devez remplacer ou supprimer un élément de tableau précis, écrire une valeur null ou affirmer une version ; utilisez Merge Patch pour les formulaires et ressources de configuration qui ne mettent à jour que quelques champs. Merge Patch ne peut pas supprimer un seul élément de tableau, et il ne peut pas écrire null, car null signifie déjà la suppression.
Pourquoi add à l'index de tableau n n'est-il pas un écrasement ?
Parce que add insère dans un tableau : l'élément à l'index n et tout ce qui suit se décalent vers l'arrière. Pour remplacer un élément existant, vous devez utiliser replace, et le chemin doit d'abord exister. Un index hors limites (n > length) doit lever une erreur ; les implémentations laxistes font que le même patch se comporte différemment selon les serveurs.
Faut-il modifier l'objet original sur place ?
Non. Un patch échoué laisse un état à moitié modifié, sauf si l'implémentation garantit l'annulation. Appliquez le patch à une copie profonde ou clonée structurellement, puis échangez la référence après succès.