RFC v0.2 R1 — Idempotency-Key replay window for POST
/api/v1/events/{event}/timetable/move. Narrow scope by design: the
12-hour ARCH §10 default would let a cached cascade-bump response
overwrite a fresh edit; 60 seconds covers honest network retry but
expires before a meaningful conflict can emerge.
Backed by the Laravel Cache facade (Redis in non-test env). Cache key
namespace `idempotency:60s:*` distinct from FormSubmission's
DB-column idempotency. Replays carry an `Idempotency-Replayed: true`
header so observability can distinguish them.
Registered as the route-middleware alias `idempotency.60s` in
bootstrap/app.php; will be applied on the move route in Step 8.
Missing or empty Idempotency-Key returns 400 with
`{"error":"idempotency_key_required"}`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
2.5 KiB
PHP
77 lines
2.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* RFC v0.2 R1 — 60-second Idempotency-Key replay window backed by
|
|
* Redis cache.
|
|
*
|
|
* Why 60s and not the 12-hour MySQL window from ARCH §10:
|
|
* a stale 12-hour replay of the cascade-bump endpoint can corrupt
|
|
* timetable state in hard-to-detect ways (the persisted lanes have
|
|
* since been edited; replaying a cached response over a fresh edit
|
|
* would silently undo it). 60 seconds covers honest network retry
|
|
* without giving stale requests a window in which to resurrect.
|
|
*
|
|
* Storage: Laravel Cache facade with the default store (Redis in
|
|
* non-test environments). The key namespace `idempotency:60s:` is
|
|
* deliberately distinct from any other idempotency surface in the
|
|
* codebase — keys never collide with the FormSubmission DB-column
|
|
* idempotency.
|
|
*
|
|
* Applied today only on `POST /api/v1/events/{event}/timetable/move`.
|
|
* Other R-numbered idempotent endpoints (RFC §6 lists POST
|
|
* /performances and POST /engagements as candidates) get the regular
|
|
* 12-hour pattern when ARCH §10 lands; this middleware is purposely
|
|
* narrow.
|
|
*/
|
|
final class IdempotencyKey60sRedis
|
|
{
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
$key = $request->header('Idempotency-Key');
|
|
|
|
if (! is_string($key) || trim($key) === '') {
|
|
return response()->json(
|
|
['error' => 'idempotency_key_required'],
|
|
400,
|
|
);
|
|
}
|
|
|
|
$cacheKey = 'idempotency:60s:'.$key;
|
|
$cached = Cache::get($cacheKey);
|
|
|
|
if (is_array($cached)) {
|
|
$response = response($cached['body'], $cached['status']);
|
|
foreach ($cached['headers'] ?? [] as $name => $value) {
|
|
$response->headers->set($name, $value);
|
|
}
|
|
$response->headers->set('Idempotency-Replayed', 'true');
|
|
|
|
return $response;
|
|
}
|
|
|
|
/** @var Response $response */
|
|
$response = $next($request);
|
|
|
|
if ($response->isSuccessful()) {
|
|
Cache::put($cacheKey, [
|
|
'status' => $response->getStatusCode(),
|
|
'body' => $response->getContent(),
|
|
'headers' => [
|
|
'Content-Type' => $response->headers->get('Content-Type'),
|
|
],
|
|
], 60);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
}
|