Skip to content

How-to : JSON Merge Patch et détection de conflits ETag

FT178 — patchlog

Implémentation de PATCH (RFC 7396 JSON Merge Patch) et sémantique PUT avec verrouillage optimiste via ETag, protection des champs immuables et intégration V.php.


Le problème avec PUT

PUT remplace la ressource entière. Les clients doivent envoyer chaque champ, même ceux qu'ils n'ont pas changés. Cela crée :

  • Conditions de course : des lecteurs concurrents voient tous deux la version 1, tous deux effectuent un PUT, le dernier gagne et supprime silencieusement les changements de l'autre.
  • Gaspillage de bande passante : payload complet même pour un changement d'un seul champ.
  • Confusion de permissions : écriture de champs que le client ne possède pas.

PATCH avec JSON Merge Patch (RFC 7396) résout les deux premiers ; ETag / If-Match résout la condition de course pour PATCH et PUT.


Sémantique JSON Merge Patch (RFC 7396)

Le document de patch décrit les changements avec une règle simple :

Valeur du patchSignification
"nouvelle valeur"Définir le champ à cette valeur
nullRéinitialiser le champ (supprimer ou revenir au défaut)
(clé absente)Laisser le champ inchangé
json
// Document avant PATCH :
{ "title": "Hello", "body": "World", "status": "draft" }

// Corps PATCH :
{ "title": "Goodbye", "status": null }

// Résultat :
{ "title": "Goodbye", "body": "World", "status": "draft" }
//                              ^^^^^     ^^^^^^^^^^^^^^
//                              inchangé  null → réinitialiser au défaut

Champs immuables

Certains champs ne doivent jamais être modifiables via PATCH ou PUT :

php
private const array IMMUTABLE_FIELDS = ['id', 'owner_id', 'version', 'created_at', 'updated_at'];

$violations = array_intersect(array_keys($body), self::IMMUTABLE_FIELDS);

if ($violations !== []) {
    return $this->responseFactory->create(
        ['error' => 'Fields are immutable: ' . implode(', ', $violations)],
        422,
    );
}

PATCH vide est valide (no-op)

RFC 7396 §3 autorise explicitement un patch vide {} :

php
// Pas de clés dans $patch → ignorer UPDATE, retourner le document actuel inchangé
if ($patch === []) {
    return $doc;  // no-op ; version NON incrémentée
}

ETag et If-Match pour le verrouillage optimiste

Format ETag

php
public function etag(): string
{
    return sprintf('"doc-%d-%d"', $this->id, $this->version);
    // ex: "doc-42-7"
}

Retourner ETag sur chaque réponse GET/PATCH/PUT :

php
return $this->responseFactory->create($doc->toArray())
    ->withHeader('ETag', $doc->etag());

Détection de conflit

php
$ifMatch = $request->getHeaderLine('If-Match');

if ($ifMatch !== '' && $ifMatch !== $doc->etag()) {
    return $this->responseFactory->create(
        ['error' => 'Version conflict. Fetch the document and retry.'],
        412,  // Precondition Failed
    );
}

If-Match absent : mise à jour optimiste sans vérification de conflit (dernier-écrit-gagne). If-Match présent et correspondant : mise à jour concurrente sûre. If-Match présent mais périmé : 412 — le client doit re-récupérer et retenter.

Incrément de version en SQL

Utiliser la base de données pour incrémenter atomiquement la version :

sql
UPDATE documents
SET title = ?, version = version + 1, updated_at = ?
WHERE id = ? AND version = ?

La clause WHERE version = ? double-vérifie le verrou optimiste au niveau DB, empêchant une écriture concurrente de se glisser entre notre lecture et notre écriture.


Intégration V.php

FT178 est le premier FT à utiliser Nene2\Validation\V comme utilitaire partagé :

php
// Paramètres de requête
$page  = V::queryInt($params, 'page', 1, PHP_INT_MAX, 1);
$limit = V::queryInt($params, 'limit', 1, 50, 20);

// En-tête auth
$ownerId = V::userId($request->getHeaderLine('X-User-Id'));

// Champs chaîne (avec limites de longueur explicites)
$title = V::str($body['title'] ?? null, 200);

// Validation d'enum
$status = V::enum($body['status'] ?? null, DocumentStatus::class);

Le piège ?? '' pour les champs de corps optionnels

php
// ❌ INCORRECT — contourne le retour null de V::str pour les entrées trop longues
$text = V::str($body['body'] ?? null, 10000) ?? '';

// ✅ CORRECT — valider quand présent, utiliser le défaut quand absent
$rawText = $body['body'] ?? null;
if ($rawText !== null) {
    $text = V::str($rawText, 10000);
    if ($text === null) {
        return $this->responseFactory->create(['error' => 'body too long'], 422);
    }
} else {
    $text = '';
}

V::str(null, ...) retourne null car null n'est pas une chaîne. V::str(chaîne_trop_longue, 10000) retourne aussi null. Utiliser ?? '' effondre les deux cas en chaîne vide — acceptant silencieusement l'entrée trop longue.


Extraction de paramètre de route

Le routeur NENE2 stocke les paramètres de chemin dans l'attribut nene2.route.parameters, pas comme attributs de requête individuels :

php
// ❌ INCORRECT
$id = $request->getAttribute('id');  // toujours null pour les paramètres de chemin

// ✅ CORRECT
$id = Router::param($request, 'id');  // lit depuis nene2.route.parameters

Checklist d'attaque (ATK-01 à ATK-12)

#TestAttente
ATK-01PATCH {"id": 999}422 — champ immuable
ATK-02PATCH {"owner_id": 99}422 — champ immuable
ATK-03PATCH {"version": 999}422 — champ immuable
ATK-04PATCH {"title": 42} (confusion de type)422 — V::str rejette les non-chaînes
ATK-05PATCH par non-propriétaire404 — protection IDOR
ATK-06If-Match ETag périmé412 — conflit de verrou optimiste
ATK-07PUT title requis manquant422
ATK-08PATCH vide {}200 — no-op valide (RFC 7396 §3)
ATK-09PATCH {"status": null}200 — réinitialiser au défaut draft
ATK-10PATCH {"status": 2} (confusion de type)422 — V::enum rejette les non-chaînes
ATK-11PATCH {"__proto__": {...}}200 — clé inconnue ignorée, pas de crash
ATK-12?limit=999999, ?page=-1, débordement 20 chiffres422 — gardes V::queryInt

Publié sous licence MIT.