Aggiornamenti parziali con JSON Merge Patch in PHP

by theArchitect
SHARE
Aggiornamenti parziali con JSON Merge Patch in PHP
© Guida-HTML5.it

Introduzione

Quando un’applicazione deve modificare una risorsa descritta in formato JSON, non è sempre necessario inviare nuovamente l’intero documento. Se un profilo contiene nome, email, indirizzo e preferenze, per cambiare soltanto l’email sarebbe inefficiente trasmettere tutti gli altri campi.

Una soluzione pratica è utilizzare il JSON Merge Patch, definito dallo standard RFC 7396. Un merge patch rappresenta le modifiche da applicare a un documento JSON esistente:

  • le proprietà presenti nella patch vengono aggiunte o aggiornate;
  • le proprietà con valore null vengono eliminate;
  • le proprietà non presenti nella patch rimangono invariate;
  • un oggetto annidato viene modificato senza dover riscrivere l’intera struttura.

Questo approccio è particolarmente utile per form di modifica, preferenze utente, documenti amministrativi e aggiornamenti parziali ricevuti da un client. In questo tutorial costruiremo una piccola implementazione in PHP, con validazione dei dati e gestione delle proprietà eliminabili.

Codice completo

<?php

declare(strict_types=1);

/**
 * Applica una JSON Merge Patch a un documento PHP.
 *
 * Regole principali:
 * - un oggetto nella patch aggiorna ricorsivamente il documento;
 * - null elimina una proprietà;
 * - un valore scalare o un array sostituisce il valore esistente.
 */
function applyMergePatch(mixed $document, mixed $patch): mixed
{
    // Se la patch non è un oggetto JSON, sostituisce completamente il documento.
    if (!is_array($patch) || isList($patch)) {
        return $patch;
    }

    // Per modificare proprietà, il documento deve essere un oggetto.
    if (!is_array($document) || isList($document)) {
        $document = [];
    }

    foreach ($patch as $property => $value) {
        if ($value === null) {
            // In JSON Merge Patch, null significa "rimuovi la proprietà".
            unset($document[$property]);
            continue;
        }

        if (is_array($value) && !isList($value)) {
            // Aggiornamento ricorsivo di un oggetto annidato.
            $existingValue = $document[$property] ?? [];
            $document[$property] = applyMergePatch($existingValue, $value);
        } else {
            // Stringhe, numeri, booleani e array sostituiscono il valore.
            $document[$property] = $value;
        }
    }

    return $document;
}

/**
 * Determina se un array PHP rappresenta una lista JSON.
 */
function isList(array $value): bool
{
    return array_is_list($value);
}

/**
 * Decodifica un JSON e verifica che sia valido.
 */
function decodeJson(string $json, string $description): mixed
{
    try {
        return json_decode(
            $json,
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    } catch (JsonException $exception) {
        throw new InvalidArgumentException(
            "JSON non valido per {$description}: " . $exception->getMessage(),
            0,
            $exception
        );
    }
}

/**
 * Codifica un valore PHP in JSON leggibile.
 */
function encodeJson(mixed $value): string
{
    return json_encode(
        $value,
        JSON_PRETTY_PRINT |
        JSON_UNESCAPED_UNICODE |
        JSON_THROW_ON_ERROR
    );
}

$documentJson = <<<´JSON´
{
    "id": 42,
    "nome": "Elena Rossi",
    "email": "[email protected]",
    "attiva": true,
    "indirizzo": {
        "citta": "Milano",
        "cap": "20100",
        "via": "Via Roma 10"
    },
    "preferenze": {
        "tema": "chiaro",
        "newsletter": true
    },
    "tag": ["cliente", "premium"]
}
JSON;

$patchJson = <<<´JSON´
{
    "email": "[email protected]",
    "indirizzo": {
        "cap": "20121",
        "via": null
    },
    "preferenze": {
        "tema": "scuro"
    },
    "tag": ["cliente", "premium", "mobile"]
}
JSON;

try {
    $document = decodeJson($documentJson, ´documento originale´);
    $patch = decodeJson($patchJson, ´merge patch´);

    $updatedDocument = applyMergePatch($document, $patch);

    echo encodeJson($updatedDocument);
} catch (InvalidArgumentException | JsonException $exception) {
    http_response_code(400);
    echo ´Errore: ´ . $exception->getMessage();
}

Spiegazione

Distinguere oggetti e liste

In PHP, sia gli oggetti JSON sia gli array JSON vengono generalmente decodificati in array quando si usa il secondo parametro di json_decode() impostato a true. È quindi necessario distinguere un oggetto da una lista.

La funzione array_is_list(), disponibile da PHP 8.1, restituisce true quando le chiavi sono numeriche consecutive, ad esempio 0, 1, 2. Un array associativo, invece, viene trattato come un oggetto JSON.

Questa distinzione è importante perché gli oggetti vengono uniti ricorsivamente, mentre gli array vengono sostituiti completamente. Per esempio, se il documento contiene:

{
    "tag": ["php", "json", "web"]
}

e la patch contiene:

{
    "tag": ["php", "api"]
}

il risultato avrà soltanto ["php", "api"]. JSON Merge Patch non aggiunge o rimuove singoli elementi da una lista: sostituisce l’intero array.

Il significato di null

Nel JSON Merge Patch, null non significa necessariamente “imposta il valore a null”. Significa invece “rimuovi questa proprietà”. Nel nostro esempio, "via": null elimina la chiave via dall’oggetto indirizzo.

Questa caratteristica deve essere conosciuta dal client e dal server. Se l’applicazione deve distinguere tra “proprietà assente”, “proprietà da eliminare” e “proprietà con valore null”, il JSON Merge Patch potrebbe non essere sufficiente. In quel caso è possibile valutare JSON Patch, basato su operazioni esplicite come add, remove e replace.

Gestione degli errori

L’opzione JSON_THROW_ON_ERROR fa sollevare un’eccezione quando il JSON non può essere decodificato o codificato. È preferibile rispetto al controllo manuale di json_last_error(), perché rende il flusso degli errori più chiaro e impedisce di utilizzare dati parzialmente interpretati.

Best practice

  • Valida la patch prima dell’applicazione: verifica che i campi modificabili abbiano il tipo corretto. Un campo email dovrebbe essere una stringa, mentre attiva dovrebbe essere booleano.
  • Limita le proprietà consentite: non permettere al client di modificare identificativi, ruoli, timestamp interni o altri campi sensibili.
  • Conserva il documento originale: la funzione dell’esempio restituisce un nuovo risultato senza modificare direttamente il documento iniziale.
  • Usa transazioni quando salvi su database: applica la patch, valida il risultato finale e salva tutto in un’unica transazione.
  • Gestisci la concorrenza: associa la risorsa a una versione o a un identificatore ETag, così da evitare che una patch sovrascriva modifiche più recenti.
  • Imposta limiti di dimensione: JSON molto grandi o profondamente annidati possono consumare molta memoria e CPU.
  • Non fidarti dei dati ricevuti: il merge patch è un meccanismo di aggiornamento, non un sistema di autorizzazione.
  • Documenta il significato di null: chi sviluppa il client deve sapere che inviare null rimuove la proprietà.

Riepilogo

JSON Merge Patch permette di descrivere aggiornamenti parziali in modo semplice e leggibile. In PHP è possibile implementarlo combinando json_decode(), json_encode(), la distinzione tra oggetti e liste e una funzione ricorsiva.

Gli oggetti vengono aggiornati proprietà per proprietà, i valori null rimuovono le chiavi e gli array vengono sostituiti completamente. Prima di usare questa tecnica in produzione è fondamentale aggiungere autorizzazione, validazione dei campi, controllo della concorrenza e gestione sicura degli errori.

Approfondisci con risorse ufficiali

SHARE