JSON Toolkit

JSON Patch in produzione: atomicità, idempotenza e conflitti concorrenti

In sintesi: JSON Patch è solo un formato di cambiamento; atomicità, idempotenza e concorrenza sono ancora compito tuo. Copre le precondizioni `test`, l'escaping dei percorsi, il rollback e il coordinamento con ETag.

Avanzato~10 min di letturaPubblicato il 2026-10-31Aggiornato il 2026-10-31
Copertina di JSON Patch in produzione

In sintesi: valida prima i percorsi di destinazione e i permessi della patch, poi aggiungi un'asserzione di concorrenza con test o If-Match; applica la patch a una copia così un fallimento non lascia nulla scritto a metà.

Il vero pericolo di una richiesta PATCH non è scrivere il campo sbagliato. È credere che la scrittura sia riuscita mentre si sovrascrive silenziosamente la modifica di qualcun altro. JSON Patch ti dà gli strumenti, ma il design della sicurezza resta tuo.

1. Una breve revisione dell'insieme di operazioni RFC 6902

JSON Patch descrive i cambiamenti come un array di operazioni, con il media type application/json-patch+json. Sei operazioni vengono eseguite in ordine di array:

Operazione Effetto Vincolo chiave
add Aggiunge un membro o inserisce un elemento di array Un indice di array può eguagliare la lunghezza; - significa la fine
remove Rimuove un membro o elemento Il percorso di destinazione deve esistere
replace Sostituisce un valore esistente Il percorso di destinazione deve esistere
move Sposta un nodo Non può spostare un nodo nel proprio percorso figlio
copy Copia un nodo Il percorso sorgente deve esistere
test Asserisce l'uguaglianza dei valori Se fallisce, l'intera patch non viene applicata

Molti ricordano solo le prime tre e dimenticano che test è ciò che trasforma una scrittura cieca in una scrittura condizionale. La semantica completa delle operazioni e i confini degli array sono trattati in JSON Pointer, Patch e Merge Patch.

2. Usare test per trasformare una patch in un aggiornamento condizionale

Una tipica patch di optimistic locking non inizia con una scrittura, ma con un'asserzione di versione:

{"op":"test","path":"/version","value":1}

Mettila in una patch completa:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

La logica è: conferma che la versione del documento è attualmente 1, poi cambiala in 2. Se un'altra richiesta ha già portato la versione a 2, il primo test fallisce, l'intera patch non viene applicata, e il server restituisce un conflitto. Questo impedisce gli aggiornamenti persi senza introdurre un lock distribuito.

Alcuni punti di design:

  • Metti test prima di ogni scrittura. Se una scrittura precedente ha già avuto effetto, l'asserzione è priva di significato;
  • Asserisci su un numero di versione o digest generato dal server, non su un campo ordinario che il client può cambiare liberamente;
  • Incrementa la versione a ogni scrittura riuscita; altrimenti due richieste che usano la stessa vecchia versione passano entrambe;
  • Il value di test deve avere esattamente lo stesso tipo JSON del valore del documento. 1 e "1" non sono uguali.

3. Escaping dei percorsi e inquinamento del prototipo

Le regole di escaping di JSON Pointer sono semplici: scrivi ~ come ~0 e / come ~1, e sostituisci ~ prima di /, altrimenti ottieni un doppio escaping.

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

Il rischio con patch esterne di solito non è l'escaping ma le chiavi pericolose. In JavaScript, chiavi come __proto__, constructor e prototype possono innescare l'inquinamento del prototipo, anche attraverso un normale add:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

Usa una allowlist invece di una denylist:

  1. Consenti alle patch di modificare solo campi di business, come /status o /items/0/quantity;
  2. Valida ogni path e from contro un'espressione regolare o un prefisso di percorso, rifiutando livelli inattesi diversi dagli indici di array;
  3. Analizza JSON in contenitori Object.create(null) o Map per ridurre l'impatto di una catena di prototipi inquinata;
  4. Limita il numero di operazioni e la dimensione in byte di ogni value.

4. Applicazione atomica e rollback

RFC 6902 richiede l'esecuzione atomica, ma l'atomicità è un requisito semantico, non un default in ogni libreria. Librerie come fast-json-patch modificano il documento sul posto quando mutateDocument è true, e un fallimento può lasciare modifiche parziali.

import { applyPatch, validate } from 'fast-json-patch';

/**
 * Apply a patch immutably and return the original document on failure.
 * @param document The original document, which callers must not mutate
 * @param patch The JSON Patch operation array to apply
 * @returns The result, including a success flag and the new document
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

Tre raccomandazioni implementative:

  • Fai prima una deep copy o uno structured clone, applica sulla copia, e scambia il riferimento solo dopo il successo;
  • Non affidarti al rollback automatico di una libreria; il comportamento sulle eccezioni differisce tra implementazioni;
  • Valida prima di eseguire: validate controlla solo la struttura, mentre i conflitti semantici necessitano comunque di test; nessuno sostituisce l'altro.

Se una patch attraversa più risorse, come cambiare un ordine e il suo inventario insieme, l'atomicità di JSON Patch vale solo dentro un singolo documento. Tra documenti necessiti di una transazione di database o di azioni compensative.

5. Idempotenza: PATCH non è naturalmente idempotente

La semantica HTTP definisce PUT e DELETE come idempotenti, ma PATCH non lo è necessariamente. Ripetere lo stesso JSON Patch può produrre risultati completamente diversi:

  • add /items/- aggiunge alla fine dell'array ogni volta, quindi i retry creano duplicati;
  • replace /version cambia la versione da 1 a 2 e riesce di nuovo su un retry, anche se l'operazione è già stata applicata;
  • remove /temp fallisce alla seconda esecuzione perché il percorso non esiste più.

Esistono tre modi comuni per ottenere l'idempotenza:

Approccio Come funziona Migliore per
Precondizione di versione La prima operazione è test /version, poi incrementa Risorse con una versione esplicita
Chiave di idempotenza Il client invia Idempotency-Key; il server memorizza il risultato Pagamenti, ordini e altre scritture
Patch di stato di destinazione Usa replace invece di add, così il risultato dipende solo dal valore finale Configurazione e campi di stato

Il pattern peggiore è una patch che mescola add e remove assumendo che i retry siano sicuri. Se una patch aggiunge o elimina qualcosa, deve essere abbinata a un'asserzione di versione o a una chiave di idempotenza.

6. Conflitti concorrenti: ETag e If-Match

Oltre a test dentro la patch, HTTP offre un meccanismo standard: il server restituisce un ETag, e il client invia If-Match sulla richiesta successiva.

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

Se la risorsa è cambiata, il server restituisce 412 Precondition Failed, e il client la recupera di nuovo prima di riprovare. If-Match: * significa "esegui purché la risorsa esista", il che non è adatto quando devi prevenire le sovrascritture.

La differenza tra test e If-Match:

  • If-Match viene controllato a livello HTTP prima del parsing del corpo, quindi è più economico;
  • test viene controllato a livello semantico della patch e può asserire qualsiasi campo senza dipendere dal supporto ETag lato server;
  • Possono essere combinati, ma non implementarne solo uno e dichiarare che l'endpoint è sicuro sotto concorrenza.

7. Differenze tra librerie e selezione

Implementazione Linguaggio Atomicità Supporto test Note
fast-json-patch JavaScript Opzionale (restituisce un nuovo documento per impostazione predefinita) Sì Ecosistema ampio; attenzione a mutateDocument
jsonpatch Python Fa rollback sulle eccezioni Sì Vicino allo standard, con errori chiari
json-patch Go Opera su una copia Sì Spesso usato insieme a JSON Merge Patch
zjsonpatch Java Sì Sì Comoda integrazione con Jackson

Non selezionare una libreria solo in base a "supporta tutte e sei le operazioni". Conferma tre cose: se un fallimento fa rollback, se test confronta rigorosamente, e se un add di array fuori intervallo solleva un errore. Le implementazioni indulgenti fanno comportare la stessa patch diversamente tra ambienti.

8. Una lista di controllo della sicurezza prima del rilascio

  • [ ] L'endpoint dichiara application/json-patch+json;
  • [ ] Le patch vengono applicate a una copia, e un fallimento non contamina mai il documento originale;
  • [ ] Ogni path e from nelle patch esterne passa un controllo di allowlist;
  • [ ] Chiavi pericolose come __proto__, constructor e prototype vengono rifiutate;
  • [ ] Numero di operazioni e dimensione di un singolo valore sono limitati;
  • [ ] test o If-Match forniscono il controllo di concorrenza;
  • [ ] Le patch vengono scritte in un log di audit con attore, timestamp e l'intero array di operazioni;
  • [ ] I confini di transazione sono espliciti quando una patch attraversa documenti.

Una patch è intrinsecamente una descrizione completa di "cosa è cambiato", il che la rende una naturale registrazione di audit. Per i codici di errore e l'idempotenza a livello API, continua con Progettazione di API JSON. Se una patch proviene dall'unione di due rami, hai anche bisogno della semantica dei conflitti, trattata in Merge Git per file JSON.

FAQ

Che codice di stato dovrebbe restituire un'operazione test fallita?

Se il conflitto deriva da un'asserzione di versione, sia 409 Conflict sia 412 Precondition Failed sono difendibili. Preferisci 412 per restare coerente con If-Match, e includi la versione corrente nel corpo della risposta così il client può riprovare.

Una patch che contiene add e remove può essere ritentata in sicurezza?

Non direttamente. add alla fine di un array duplica i dati, e remove fallisce alla seconda esecuzione perché il percorso è sparito. Aggiungi una precondizione di versione o una chiave di idempotenza così i retry vengono riconosciuti come la stessa operazione.

JSON Patch può garantire transazioni tra documenti?

No. L'atomicità di RFC 6902 si applica a un singolo documento JSON. Quando sono coinvolti ordini, inventario o altre risorse, usa transazioni di database, compensazione Saga o una coda di messaggi per la consistenza.

Perché una patch non deve mai scrivere su __proto__?

Perché la maggior parte delle implementazioni JavaScript memorizza il documento in oggetti ordinari, scrivere __proto__ può cambiare la catena di prototipi e influenzare ogni oggetto successivo. Una allowlist di percorsi è la difesa più affidabile; non affidarti solo al filtraggio interno di una libreria.

Come rendo idempotente una richiesta PATCH?

Esistono tre approcci comuni: inizia la patch con test /version e incrementa la versione al successo, fai memorizzare al server il risultato per una Idempotency-Key, oppure usa replace invece di add così il risultato dipende solo dal valore finale. Se una patch aggiunge o elimina qualcosa, deve essere abbinata a un'asserzione di versione o a una chiave di idempotenza; non può essere semplicemente ritentata.

Devo usare sia test sia If-Match?

Non devi usarli entrambi, ma implementarne solo uno non rende un endpoint sicuro sotto concorrenza. If-Match viene controllato a livello HTTP prima del parsing del corpo, quindi è più economico; test viene controllato a livello semantico della patch e può asserire qualsiasi campo senza dipendere dal supporto ETag lato server.