Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
IdempotencyMiddleware
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
5 / 5
21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 handleIdempotentRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 restoreCachedResponse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 storeResponse
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Middleware;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Shared\Infrastructure\Http\ApiResponseTrait;
12use Psr\Http\Message\ResponseFactoryInterface;
13use Psr\Http\Message\ResponseInterface;
14use Psr\Http\Message\ServerRequestInterface;
15use Psr\Http\Server\MiddlewareInterface;
16use Psr\Http\Server\RequestHandlerInterface;
17use Psr\SimpleCache\CacheInterface;
18use Throwable;
19
20/**
21 * Idempotency Key Middleware (IETF Draft / OWASP ASVS V13).
22 *
23 * Prevents duplicate state-modifying operations (POST) caused by network retries.
24 * Caches successful responses keyed by Idempotency-Key header.
25 *
26 * @package App\Core\Api\Middleware
27 */
28final readonly class IdempotencyMiddleware implements MiddlewareInterface
29{
30    use ApiResponseTrait;
31
32    /** @var string Idempotency header key name. */
33    public const string HEADER_NAME = 'Idempotency-Key';
34
35    /**
36     * IdempotencyMiddleware constructor.
37     *
38     * @param ResponseFactoryInterface $responseFactory PSR-17 response factory.
39     * @param CacheInterface|null      $cache           PSR-16 Cache storage.
40     * @param int                      $ttlSeconds      Cached response TTL in seconds (default 24h).
41     */
42    public function __construct(
43        private ResponseFactoryInterface $responseFactory,
44        private ?CacheInterface $cache = null,
45        private int $ttlSeconds = 86400
46    ) {
47    }
48
49    /**
50     * Processes request, checking for idempotent cached response or storing new result.
51     *
52     * @param ServerRequestInterface  $request Server request.
53     * @param RequestHandlerInterface $handler Request handler.
54     * @return ResponseInterface Response instance.
55     */
56    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
57    {
58        $idempotencyKey = trim($request->getHeaderLine(self::HEADER_NAME));
59        if (strtoupper($request->getMethod()) !== 'POST' || $this->cache === null || $idempotencyKey === '') {
60            return $handler->handle($request);
61        }
62
63        if (preg_match('/^[a-zA-Z0-9_\-]{8,64}$/', $idempotencyKey) !== 1) {
64            return $this->jsonError(
65                $this->responseFactory,
66                'Invalid Idempotency-Key format. Must be 8-64 alphanumeric characters.',
67                400
68            );
69        }
70
71        return $this->handleIdempotentRequest($request, $handler, $idempotencyKey);
72    }
73
74    /**
75     * Executes cached replay lookup or executes handler and caches response.
76     *
77     * @param ServerRequestInterface  $request        Incoming request.
78     * @param RequestHandlerInterface $handler        Downstream handler.
79     * @param string                 $idempotencyKey Validated key string.
80     * @return ResponseInterface Handled or replayed response.
81     */
82    private function handleIdempotentRequest(
83        ServerRequestInterface $request,
84        RequestHandlerInterface $handler,
85        string $idempotencyKey
86    ): ResponseInterface {
87        $cacheKey = 'idempotency_' . hash('sha256', $idempotencyKey);
88
89        try {
90            /** @var array{status: int, headers: array<string, list<string>>, body: string}|null $cached */
91            $cached = $this->cache?->get($cacheKey);
92            if (is_array($cached) && isset($cached['status'], $cached['body'])) {
93                return $this->restoreCachedResponse($cached);
94            }
95        } catch (Throwable) {
96            // Non-blocking cache retrieval failure
97        }
98
99        $response = $handler->handle($request);
100
101        if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
102            $this->storeResponse($cacheKey, $response);
103        }
104
105        return $response;
106    }
107
108    /**
109     * Reconstructs response from cached payload.
110     *
111     * @param array{status: int, headers: array<string, list<string>>, body: string} $cached Cached data.
112     * @return ResponseInterface PSR-7 response.
113     */
114    private function restoreCachedResponse(array $cached): ResponseInterface
115    {
116        $response = $this->responseFactory->createResponse($cached['status'])
117            ->withHeader('X-Idempotency-Replayed', 'true')
118            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE);
119
120        if (isset($cached['headers']) && is_array($cached['headers'])) {
121            foreach ($cached['headers'] as $headerName => $headerValues) {
122                if ($headerName !== 'Content-Type' && is_array($headerValues)) {
123                    $response = $response->withHeader($headerName, $headerValues);
124                }
125            }
126        }
127
128        $response->getBody()->write($cached['body']);
129
130        return $response;
131    }
132
133    /**
134     * Stores successful response payload in cache.
135     *
136     * @param string            $cacheKey Target cache key.
137     * @param ResponseInterface $response HTTP response to cache.
138     * @return void
139     */
140    private function storeResponse(string $cacheKey, ResponseInterface $response): void
141    {
142        $stream = $response->getBody();
143        if ($stream->isSeekable()) {
144            $stream->rewind();
145        }
146        $body = (string) $stream;
147
148        try {
149            $this->cache?->set($cacheKey, [
150                'status'  => $response->getStatusCode(),
151                'headers' => $response->getHeaders(),
152                'body'    => $body,
153            ], $this->ttlSeconds);
154        } catch (Throwable) {
155            // Non-blocking cache store failure
156        }
157    }
158}