feat: add middleware
This commit is contained in:
parent
863f3fffc5
commit
dfaec2d948
104
app/Http/Middleware/IdempotencyMiddleware.php
Normal file
104
app/Http/Middleware/IdempotencyMiddleware.php
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class IdempotencyMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$idempotencyKey = $request->header('X-Idempotency-Key');
|
||||||
|
|
||||||
|
if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'X-Idempotency-Key MUST be a valid UUID.',
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = $this->cacheKey($request, $idempotencyKey);
|
||||||
|
$lockKey = $this->lockKey($request, $idempotencyKey);
|
||||||
|
|
||||||
|
$lock = Cache::lock($lockKey, config('slots.idempotency_lock_seconds'));
|
||||||
|
if (! $lock->get()) {
|
||||||
|
return $this->waitForCachedResponse($cacheKey) ?? $this->inProgressResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
return $this->replayResponse($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof JsonResponse) {
|
||||||
|
Cache::put($cacheKey, [
|
||||||
|
'status' => $response->getStatusCode(),
|
||||||
|
'body' => $response->getData(true),
|
||||||
|
], config('slots.idempotency_cache_ttl_seconds'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForCachedResponse(string $cacheKey): ?JsonResponse
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + config('slots.idempotency_wait_seconds');
|
||||||
|
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached) && isset($cached['body'], $cached['status'])) {
|
||||||
|
return $this->replayResponse($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inProgressResponse(): ?JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Conflict. Retry with same idempotency key',
|
||||||
|
], Response::HTTP_CONFLICT)
|
||||||
|
->header('X-Retry-After', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{status: int, body: string} $data
|
||||||
|
*/
|
||||||
|
private function replayResponse(array $data): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($data['body'], (int) $data['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheKey(Request $request, string $idempotencyKey): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'idempotency:response:%s:%s:%s',
|
||||||
|
strtoupper($request->method()),
|
||||||
|
$request->path(),
|
||||||
|
$idempotencyKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockKey(Request $request, string $idempotencyKey): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'idempotency:lock:%s:%s:%s',
|
||||||
|
strtoupper($request->method()),
|
||||||
|
$request->path(),
|
||||||
|
$idempotencyKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user