Skip to content

How-to: JSON Merge Patch & ETag Conflict Detection ​

FT178 — patchlog

Implementing PATCH (RFC 7396 JSON Merge Patch) and PUT semantics with optimistic locking via ETag, immutable field protection, and V.php integration.


The problem with PUT ​

PUT replaces the entire resource. Clients must send every field, even those they didn't change. This creates:

  • Race conditions: concurrent readers both see version 1, both PUT, last one wins and silently discards the other's changes.
  • Bandwidth waste: full payload even for a one-field change.
  • Permission confusion: writing fields the client doesn't own.

PATCH with JSON Merge Patch (RFC 7396) solves the first two; ETag / If-Match solves the race condition for both PATCH and PUT.


JSON Merge Patch semantics (RFC 7396) ​

The patch document describes changes using a simple rule:

Patch valueMeaning
"new value"Set the field to this value
nullReset the field (delete or revert to default)
(key absent)Leave the field unchanged
json
// Document before PATCH:
{ "title": "Hello", "body": "World", "status": "draft" }

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

// Result:
{ "title": "Goodbye", "body": "World", "status": "draft" }
//                              ^^^^^     ^^^^^^^^^^^^^^
//                              unchanged  null → reset to default

Immutable fields ​

Some fields must never be modifiable via PATCH or 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,
    );
}

Empty PATCH is valid (no-op) ​

RFC 7396 §3 explicitly allows an empty patch {}:

php
// No keys in $patch → skip UPDATE, return current document unchanged
if ($patch === []) {
    return $doc;  // no-op; version NOT incremented
}

ETag and If-Match for optimistic locking ​

ETag format ​

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

Return ETag on every GET/PATCH/PUT response:

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

Conflict detection ​

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: optimistic update without conflict check (last-write-wins). If-Match present and matching: safe concurrent update. If-Match present but stale: 412 — client must re-fetch and retry.

Version increment in SQL ​

Use the database to atomically increment version:

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

The WHERE version = ? clause double-checks the optimistic lock at the DB level, preventing a concurrent write from sneaking in between our read and write.


V.php integration ​

FT178 is the first FT to use Nene2\Validation\V as a shared utility:

php
// Query params
$page  = V::queryInt($params, 'page', 1, PHP_INT_MAX, 1);
$limit = V::queryInt($params, 'limit', 1, 50, 20);

// Auth header
$ownerId = V::userId($request->getHeaderLine('X-User-Id'));

// String fields (with explicit length limits)
$title = V::str($body['title'] ?? null, 200);

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

The ?? '' trap for optional body fields ​

php
// ❌ WRONG — bypasses V::str null return for over-length input
$text = V::str($body['body'] ?? null, 10000) ?? '';

// ✅ CORRECT — validate when present, default when 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, ...) returns null because null is not a string.
V::str(too_long_string, 10000) also returns null.
Using ?? '' collapses both cases into empty string — silently accepting the over-length input.


Route parameter extraction ​

The NENE2 Router stores path parameters in the nene2.route.parameters attribute, not as individual request attributes:

php
// ❌ WRONG
$id = $request->getAttribute('id');  // always null for path params

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

Attack checklist (ATK-01 through ATK-12) ​

#TestExpectation
ATK-01PATCH {"id": 999}422 — immutable field
ATK-02PATCH {"owner_id": 99}422 — immutable field
ATK-03PATCH {"version": 999}422 — immutable field
ATK-04PATCH {"title": 42} (type confusion)422 — V::str rejects non-string
ATK-05PATCH by non-owner404 — IDOR protection
ATK-06If-Match stale ETag412 — optimistic lock conflict
ATK-07PUT missing required title422
ATK-08PATCH empty {}200 — valid no-op (RFC 7396 §3)
ATK-09PATCH {"status": null}200 — reset to default draft
ATK-10PATCH {"status": 2} (type confusion)422 — V::enum rejects non-string
ATK-11PATCH {"__proto__": {...}}200 — unknown key ignored, no crash
ATK-12?limit=999999, ?page=-1, 20-digit overflow422 — V::queryInt guards

Released under the MIT License.