JSON Pointer, Patch e Merge Patch
In sintesi: Tre specifiche brevi formano un protocollo di cambiamento: Pointer localizza, Patch modifica con precisione, Merge Patch unisce parzialmente. Scegliere male danneggia array e concorrenza.

In sintesi: usa JSON Pointer per localizzare per posizione e JSONPath per filtrare o ricorrere; affida inserimento/rimozione di array, scritture null e asserzioni di versione a JSON Patch, e riserva Merge Patch per configurazione piatta e risorse di form.
Localizzare e modificare sono due cose diverse, e le specifiche le dividono in tre documenti. Il prezzo usuale del confonderle è "l'intero array è stato sostituito" e nessuno se ne accorge.
1. JSON Pointer: la sintassi di localizzazione più corta
RFC 6901 fa esattamente una cosa: puntare a un nodo in un documento con una stringa.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
Tre regole:
- La stringa vuota punta al nodo radice stesso, non a un "nodo vuoto";
- Ogni
/separa un token di riferimento; gli array usano indici decimali (gli zeri iniziali non sono ammessi, quindi01è invalido); - Dentro un token,
~si scrive come~0e/come~1— sostituisci~prima di/, altrimenti finisci per fare doppio escaping.
/**
* 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('');
}
Il valore si mostra meglio quando la chiave stessa contiene /: il pointer per {"a/b": 1} è /a~1b, non /a/b (quest'ultimo significa b sotto a).
Quando viene usato dentro un URL, è necessaria anche la codifica percentuale, perché # e ~ hanno significati speciali in un frammento.
2. JSON Patch: una sequenza atomica di sei operazioni
RFC 6902 descrive i cambiamenti con un array di operazioni, media type 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" }
]
| Operazione | Semantica | Confine |
|---|---|---|
add |
Aggiunge un membro o inserisce un elemento di array | L'indice di array può eguagliare la lunghezza (append); - significa la fine |
remove |
Rimuove | Il percorso deve esistere |
replace |
Sostituisce un valore esistente | Il percorso deve esistere (equivalente a remove + add, ma richiede che il percorso esista prima) |
move |
Sposta | Non puoi spostare un nodo sotto il proprio sottopercorso |
copy |
Copia | La sorgente deve esistere |
test |
Asserisce l'uguaglianza | Se fallisce, l'intera patch non viene applicata; usato per l'optimistic locking |
La proprietà chiave è l'atomicità: se qualsiasi operazione fallisce, l'intera patch fa rollback, quindi non esiste uno stato intermedio in cui "le prime tre sono state applicate e la quarta è fallita".
Questo lo rende un ottimo corpo di richiesta PATCH.
2.1 Usare test per la concorrenza ottimistica
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Il client fa prima GET dell'etag e lo scrive nell'operazione test. Su scritture concorrenti, il test della seconda richiesta fallisce e il server restituisce
409 Conflict o 412 Precondition Failed. Questo è molto più sicuro del last-write-wins e non richiede un lock distribuito.
2.2 Le operazioni sugli array sono le più soggette a errori
// 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 all'indice di array n è un inserimento, non una sovrascrittura; un indice fuori intervallo (n > length) deve sollevare un errore.
Alcune implementazioni sono indulgenti su questo, il che fa comportare la stessa patch diversamente tra server.
3. Merge Patch: un frammento che assomiglia alla destinazione
RFC 7386 ha solo quattro regole:
- La destinazione è un oggetto e la patch è un oggetto → unisci ricorsivamente;
- Il valore di una chiave nella patch è
null→ elimina quella chiave dalla destinazione; - Il valore di una chiave nella patch non è un oggetto → sostituisci direttamente;
- La patch stessa non è un oggetto → l'intera destinazione viene sostituita.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
Il media type è application/merge-patch+json. Si legge come un "aggiornamento parziale", il che lo rende particolarmente facile da usare male:
- Gli array possono solo essere sostituiti in blocco. Vuoi eliminare
list[2]? Merge Patch non può farlo; - Non puoi scrivere
null.nullè già preso per significare eliminazione; - Non può esprimere un terzo stato tra "lascia questo campo com'è" e "elimina questo campo" (per esempio, impostarlo esplicitamente a vuoto).
4. Matrice delle capacità: quale scegliere
| Requisito | JSON Patch | Merge Patch |
|---|---|---|
| Sostituire con precisione un elemento specifico di un array | ✓ | ✗ |
| Eliminare un elemento da un array | ✓ | ✗ |
| Aggiornare parzialmente diversi campi | ✓ (più replace) | ✓ (il più conciso) |
Scrivere un valore null |
✓ | ✗ |
| Controllo di concorrenza (asserzione di versione) | ✓ (test) | ✗ (solo via If-Match) |
| Leggibilità della patch | Media | Buona (sembra i dati stessi) |
| Complessità implementativa | Alta | Bassa |
| Forme di risorse adatte | Profondamente annidate, simili a liste | Configurazione piatta, form |
Regole pratiche:
- Form, pagine di impostazioni, risorse di configurazione → Merge Patch; il frontend invia solo i campi modificati;
- Aggiunte e rimozioni di liste, transizioni di macchine a stati, qualsiasi cosa che necessiti protezione dalla concorrenza → JSON Patch;
- Entrambi possono coesistere: endpoint diversi dichiarano
Content-Typediversi, e il server smista per tipo.
5. Note implementative
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);
Quattro punti facilmente trascurati:
- Non modificare sul posto oggetti condivisi; una patch fallita lascia uno stato mezzo modificato (a meno che l'implementazione garantisca il rollback);
- Validazione dei percorsi: le patch dall'esterno devono validare ogni
path/frome rifiutare chiavi come__proto__econstructor; - Limiti di dimensione: limita il numero di voci della patch per evitare che una singola patch esegua centinaia di migliaia di operazioni;
- Audit: le patch sono naturalmente adatte ai log di audit — sono una descrizione completa di "cosa è cambiato".
6. Semantica degli errori
| Situazione | Codice di stato suggerito | Spiegazione |
|---|---|---|
| La patch non è un array / è strutturalmente invalida | 400 Bad Request |
Il formato del corpo della richiesta è sbagliato |
Il nodo referenziato da path non esiste |
422 Unprocessable Entity |
Sintatticamente valido ma semanticamente non eseguibile |
Un'operazione test è fallita |
409 o 412 |
Indica un conflitto di versione |
| Nessun permesso per modificare quel percorso | 403 Forbidden |
Non mascherarlo con 404 a meno che tu non voglia deliberatamente nascondere l'esistenza della risorsa |
Distinguere 400 da 422 conta: il primo significa "non riesco a capire la tua patch", il secondo "la capisco, ma questi dati non permettono quel cambiamento".
7. Lista di controllo
- [ ] L'endpoint dichiara il
Content-Typecorretto (json-patch+json/merge-patch+json); - [ ] L'applicazione della patch è atomica, e un fallimento non lascia risultati parziali;
- [ ] Le patch esterne vengono validate contro una whitelist di percorsi e un limite di numero di voci;
- [ ] Il controllo di concorrenza usa
testoIf-Match; - [ ] Le patch vengono scritte nel log di audit;
- [ ] Gli scenari di array scelgono esplicitamente JSON Patch; non contare su Merge Patch.
FAQ
Come decido tra JSON Patch e Merge Patch?
Usa JSON Patch quando devi sostituire o rimuovere un elemento specifico di un array, scrivere un valore null o asserire una versione; usa Merge Patch per form e risorse di configurazione che aggiornano solo pochi campi. Merge Patch non può eliminare un singolo elemento di un array, e non può scrivere null, perché null significa già eliminazione.
Perché add all'indice di array n non è una sovrascrittura?
Perché add inserisce in un array: l'elemento all'indice n e tutto ciò che segue si sposta indietro. Per sostituire un elemento esistente devi usare replace, e il percorso deve esistere prima. Un indice fuori intervallo (n > length) dovrebbe sollevare un errore; le implementazioni indulgenti fanno comportare la stessa patch diversamente tra server.
Devo modificare l'oggetto originale sul posto?
No. Una patch fallita lascia uno stato mezzo modificato a meno che l'implementazione garantisca il rollback. Applica la patch a una copia deep-copied o structured-cloned, poi scambia il riferimento dopo il successo.