From d0e5a199dd33c01ae4a424403327306d63b434be Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 05:57:03 +0200 Subject: [PATCH] feat(idempotency): IdempotencyKeyMiddleware + RequestBodyHasher (#173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the PSR-15 middleware that drives the storage layer from #172. Reads Idempotency-Key from the request, hashes the request body, and coordinates with the store so a replayed request returns the original response and a key reused with a different payload is rejected with 409. Behaviour matrix - GET / HEAD / OPTIONS → pass through; no caching. - Header absent, mode=optional → pass through. - Header absent, mode=required → 400 with {error} envelope. - Header malformed (>255 chars, ctrl chars, or whitespace) → 400. - Key unseen → claim; execute; cache; return. - Key seen, same hash, completed → replay + Idempotency-Replayed: true. - Key seen, same hash, in-progress → wait + retry up to maxWaitMs; replay when ready; 409 on timeout or release. - Key seen, different hash → 409. - Handler throws → release claim; re-throw. - Streaming response (chunked or text/event-stream) → pass through without caching. Headers - Response headers are stored on an allow-list (default Content-Type, Location, Link). Set-Cookie / Authorization / anything not on the list is never written to shared storage. - Idempotency-Replayed: true header on every cached return distinguishes a fresh execution from a replay for observability + agents. RequestBodyHasher - SHA-256 over the raw bytes; not parsed JSON. Semantically equivalent bodies with different whitespace produce different hashes — the rule is "no surprises about what bytes hashed". - Rewinds the body stream after reading so downstream handlers see the same content from position 0. Composer - univeros/idempotency picks up PSR HTTP message + factory + server-middleware deps. No coupling to univeros/http; the package stays portable. Tests (20 new) - RequestBodyHasher: byte equality, whitespace sensitivity, rewind guarantee, empty-body determinism. - IdempotencyKeyMiddleware: safe-method passthrough, optional / required mode header handling, malformed key, fresh execution + cache, replay + Idempotency-Replayed header, sensitive-header filtering, hash-mismatch 409, streaming + chunked skip-cache, handler-throw release, in-progress timeout, in-progress release, body-rebuild for downstream emission. Out of scope (per #171) - The idempotency: spec block + scaffolder integration — #174. - The x-altair-idempotency round-trip activation — #175. - The package doc + benchmark variant — #176. Part of #171. Closes #173. --- .agent/packages/idempotency.md | 11 + .../Idempotency/Hash/RequestBodyHasher.php | 49 +++ .../Middleware/IdempotencyKeyMiddleware.php | 240 +++++++++++ src/Altair/Idempotency/composer.json | 6 +- .../Hash/RequestBodyHasherTest.php | 69 ++++ .../IdempotencyKeyMiddlewareTest.php | 381 ++++++++++++++++++ 6 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 src/Altair/Idempotency/Hash/RequestBodyHasher.php create mode 100644 src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.php create mode 100644 tests/Idempotency/Hash/RequestBodyHasherTest.php create mode 100644 tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php diff --git a/.agent/packages/idempotency.md b/.agent/packages/idempotency.md index f534e9ee..5ce4a985 100644 --- a/.agent/packages/idempotency.md +++ b/.agent/packages/idempotency.md @@ -14,13 +14,24 @@ ## Concrete classes - `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface` +- `IdempotencyKeyMiddleware` _(final)_ — implements `MiddlewareInterface` - `InMemoryStore` _(final)_ — implements `IdempotencyStoreInterface` - `RedisStore` _(final)_ — implements `IdempotencyStoreInterface` +- `RequestBodyHasher` - `StoredResponse` _(final)_ ## Tests as documentation +- `tests/Idempotency/Hash/RequestBodyHasherTest.php` +- `tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php` - `tests/Idempotency/Storage/ApcuStoreTest.php` - `tests/Idempotency/Storage/InMemoryStoreTest.php` - `tests/Idempotency/Storage/RedisStoreTest.php` - `tests/Idempotency/Storage/StoredResponseTest.php` + +## Related packages + +- `psr/http-factory` +- `psr/http-message` +- `psr/http-server-handler` +- `psr/http-server-middleware` diff --git a/src/Altair/Idempotency/Hash/RequestBodyHasher.php b/src/Altair/Idempotency/Hash/RequestBodyHasher.php new file mode 100644 index 00000000..6e027b7c --- /dev/null +++ b/src/Altair/Idempotency/Hash/RequestBodyHasher.php @@ -0,0 +1,49 @@ +getBody(); + + if ($body->isSeekable()) { + $body->rewind(); + } + + $contents = (string) $body; + + if ($body->isSeekable()) { + $body->rewind(); + } + + return hash('sha256', $contents); + } +} diff --git a/src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.php b/src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.php new file mode 100644 index 00000000..ca45d653 --- /dev/null +++ b/src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.php @@ -0,0 +1,240 @@ +255 chars or ctrl/ws) | 400 with `{error}` envelope. | + * | Key unseen | Claim; execute; cache; return. | + * | Key seen, same hash, completed | Replay + `Idempotency-Replayed: true` | + * | Key seen, same hash, in-progress (≤ maxWait) | Wait + retry; replay when ready. | + * | Key seen, same hash, in-progress (> maxWait) | 409 conflict. | + * | Key seen, different hash | 409 conflict. | + * | Handler throws | Release claim; re-throw. | + * | Response is streaming | Pass through without caching. | + * + * Response headers are stored on an allow-list basis (default + * `Content-Type`, `Location`, `Link`) so that sensitive headers + * (`Set-Cookie`, `Authorization`) never end up in shared storage. + */ +final readonly class IdempotencyKeyMiddleware implements MiddlewareInterface +{ + public const string MODE_OPTIONAL = 'optional'; + + public const string MODE_REQUIRED = 'required'; + + public const string HEADER_KEY = 'Idempotency-Key'; + + public const string HEADER_REPLAYED = 'Idempotency-Replayed'; + + private const array SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']; + + private const array DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'Location', 'Link']; + + /** + * @param list $allowedResponseHeaders Subset of response headers that should be replayed verbatim. + */ + public function __construct( + private IdempotencyStoreInterface $store, + private ResponseFactoryInterface $responseFactory, + private StreamFactoryInterface $streamFactory, + private int $ttlSeconds, + private string $mode = self::MODE_OPTIONAL, + private RequestBodyHasher $hasher = new RequestBodyHasher(), + private array $allowedResponseHeaders = self::DEFAULT_ALLOWED_HEADERS, + private int $maxWaitMs = 500, + private int $waitIntervalMs = 50, + ) {} + + #[Override] + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (\in_array(strtoupper($request->getMethod()), self::SAFE_METHODS, true)) { + return $handler->handle($request); + } + + $key = $request->getHeaderLine(self::HEADER_KEY); + if ($key === '') { + if ($this->mode === self::MODE_REQUIRED) { + return $this->errorResponse(400, 'Idempotency-Key header required for this endpoint.'); + } + + return $handler->handle($request); + } + + if (!$this->isValidKey($key)) { + return $this->errorResponse(400, 'Idempotency-Key header is malformed.'); + } + + $hash = $this->hasher->hash($request); + $existing = $this->store->claim($key, $hash, $this->ttlSeconds); + + if (!$existing instanceof StoredResponse) { + return $this->executeAndCache($key, $hash, $request, $handler); + } + + if ($existing->requestHash !== $hash) { + return $this->errorResponse(409, 'Idempotency-Key reused with a different payload.'); + } + + if (!$existing->inProgress) { + return $this->replay($existing); + } + + return $this->waitForInProgress($key); + } + + private function executeAndCache( + string $key, + string $hash, + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + try { + $response = $handler->handle($request); + } catch (Throwable $throwable) { + $this->store->release($key); + + throw $throwable; + } + + if ($this->isStreaming($response)) { + $this->store->release($key); + + return $response; + } + + $bodyContents = (string) $response->getBody(); + $stored = StoredResponse::completed( + requestHash: $hash, + status: $response->getStatusCode(), + headers: $this->filterHeaders($response), + body: $bodyContents, + createdAt: time(), + ); + $this->store->complete($key, $stored, $this->ttlSeconds); + + // Rebuild the body so downstream output is not consumed. + return $response->withBody($this->streamFactory->createStream($bodyContents)); + } + + private function waitForInProgress(string $key): ResponseInterface + { + $waited = 0; + while ($waited < $this->maxWaitMs) { + usleep($this->waitIntervalMs * 1000); + $waited += $this->waitIntervalMs; + + $current = $this->store->get($key); + if (!$current instanceof StoredResponse) { + return $this->errorResponse(409, 'Idempotency-Key claim was released; retry the request.'); + } + + if (!$current->inProgress) { + return $this->replay($current); + } + } + + return $this->errorResponse(409, 'Idempotency-Key claim is still in progress; retry later.'); + } + + private function replay(StoredResponse $stored): ResponseInterface + { + $response = $this->responseFactory->createResponse($stored->status); + foreach ($stored->headers as $name => $values) { + foreach ($values as $value) { + $response = $response->withAddedHeader($name, $value); + } + } + + $response = $response->withHeader(self::HEADER_REPLAYED, 'true'); + + return $response->withBody($this->streamFactory->createStream($stored->body)); + } + + /** + * @return array> + */ + private function filterHeaders(ResponseInterface $response): array + { + $kept = []; + foreach ($this->allowedResponseHeaders as $name) { + if (!$response->hasHeader($name)) { + continue; + } + + $kept[$name] = array_values($response->getHeader($name)); + } + + return $kept; + } + + private function isStreaming(ResponseInterface $response): bool + { + if (strtolower($response->getHeaderLine('Transfer-Encoding')) === 'chunked') { + return true; + } + + $contentType = strtolower($response->getHeaderLine('Content-Type')); + + return str_starts_with($contentType, 'text/event-stream'); + } + + private function isValidKey(string $key): bool + { + if ($key === '' || \strlen($key) > 255) { + return false; + } + + // Reject ASCII control characters (including tab/newline) and any + // whitespace; the spec leaves the rest of the printable set alone. + return preg_match('/[\x00-\x20\x7F\s]/', $key) !== 1; + } + + private function errorResponse(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => $message], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($body === false) { + $body = '{"error":"idempotency error"}'; + } + + $response = $this->responseFactory->createResponse($status); + $response = $response->withHeader('Content-Type', 'application/json'); + + return $response->withBody($this->streamFactory->createStream($body)); + } +} diff --git a/src/Altair/Idempotency/composer.json b/src/Altair/Idempotency/composer.json index c20538e4..420be34c 100644 --- a/src/Altair/Idempotency/composer.json +++ b/src/Altair/Idempotency/composer.json @@ -8,7 +8,11 @@ "source": "https://github.com/univeros/framework" }, "require": { - "php": ">=8.3" + "php": ">=8.3", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" }, "suggest": { "ext-apcu": "Required for the single-host ApcuStore adapter.", diff --git a/tests/Idempotency/Hash/RequestBodyHasherTest.php b/tests/Idempotency/Hash/RequestBodyHasherTest.php new file mode 100644 index 00000000..1397ef53 --- /dev/null +++ b/tests/Idempotency/Hash/RequestBodyHasherTest.php @@ -0,0 +1,69 @@ +requestWithBody('{"a":1}'); + + $hash = (new RequestBodyHasher())->hash($request); + + self::assertSame(hash('sha256', '{"a":1}'), $hash); + } + + public function testDifferentBodiesProduceDifferentHashes(): void + { + $a = (new RequestBodyHasher())->hash($this->requestWithBody('{"a":1}')); + $b = (new RequestBodyHasher())->hash($this->requestWithBody('{"a":2}')); + + self::assertNotSame($a, $b); + } + + public function testWhitespaceCountsTowardsHash(): void + { + // Semantically equivalent JSON bodies produce different hashes. + // Applications that want canonical hashing add a canonicalising + // middleware upstream of this one. + $a = (new RequestBodyHasher())->hash($this->requestWithBody('{"a":1}')); + $b = (new RequestBodyHasher())->hash($this->requestWithBody('{"a": 1}')); + + self::assertNotSame($a, $b); + } + + public function testRewindsBodyForDownstreamConsumers(): void + { + $request = $this->requestWithBody('{"a":1}'); + $hasher = new RequestBodyHasher(); + + $hasher->hash($request); + + $body = $request->getBody(); + $body->rewind(); + self::assertSame('{"a":1}', (string) $body); + } + + public function testEmptyBodyHashesDeterministically(): void + { + $hash = (new RequestBodyHasher())->hash($this->requestWithBody('')); + + self::assertSame(hash('sha256', ''), $hash); + } + + private function requestWithBody(string $contents): ServerRequest + { + $stream = new Stream('php://temp', 'r+'); + $stream->write($contents); + $stream->rewind(); + + return (new ServerRequest())->withBody($stream); + } +} diff --git a/tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php b/tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php new file mode 100644 index 00000000..c7e66dd2 --- /dev/null +++ b/tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php @@ -0,0 +1,381 @@ +middleware($store); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(200, ['ok' => true])); + + $response = $middleware->process( + $this->request('GET', '/users', body: '', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(200, $response->getStatusCode()); + // Nothing was claimed. + self::assertNull($store->get('abc')); + } + + public function testOptionalModeWithoutHeaderPassesThrough(): void + { + $middleware = $this->middleware(new InMemoryStore()); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{"email":"a@b.c"}'), + $handler, + ); + + self::assertSame(201, $response->getStatusCode()); + } + + public function testRequiredModeWithoutHeaderReturns400(): void + { + $middleware = $this->middleware(new InMemoryStore(), mode: IdempotencyKeyMiddleware::MODE_REQUIRED); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{"email":"a@b.c"}'), + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])), + ); + + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('Idempotency-Key header required', (string) $response->getBody()); + } + + public function testMalformedKeyReturns400(): void + { + $middleware = $this->middleware(new InMemoryStore()); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, [])); + + // Whitespace in the key — invalid. + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => "bad key"]), + $handler, + ); + + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('malformed', (string) $response->getBody()); + } + + public function testTooLongKeyReturns400(): void + { + $middleware = $this->middleware(new InMemoryStore()); + $longKey = str_repeat('a', 256); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => $longKey]), + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, [])), + ); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testFreshKeyExecutesHandlerAndCachesResponse(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{"email":"a@b.c"}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame('{"id":"u_1"}', (string) $response->getBody()); + + $stored = $store->get('abc'); + self::assertInstanceOf(StoredResponse::class, $stored); + self::assertFalse($stored->inProgress); + self::assertSame('{"id":"u_1"}', $stored->body); + } + + public function testReplayReturnsCachedResponseWithReplayedHeader(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $callCount = 0; + $handler = $this->handler(function () use (&$callCount): ResponseInterface { + $callCount++; + return $this->jsonResponse(201, ['id' => 'u_1']); + }); + + $body = '{"email":"a@b.c"}'; + $request = $this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']); + $middleware->process($request, $handler); + + // Second call with the same key + same body. + $second = $middleware->process( + $this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(1, $callCount, 'handler should run only once'); + self::assertSame(201, $second->getStatusCode()); + self::assertSame('{"id":"u_1"}', (string) $second->getBody()); + self::assertSame('true', $second->getHeaderLine(IdempotencyKeyMiddleware::HEADER_REPLAYED)); + } + + public function testReplayPreservesAllowedHeadersAndDropsSensitiveOnes(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(function (): ResponseInterface { + $response = $this->jsonResponse(201, ['id' => 'u_1']); + + return $response + ->withHeader('Location', '/users/u_1') + ->withHeader('Set-Cookie', 'session=secret') + ->withHeader('Authorization', 'Bearer leaked'); + }); + + $body = '{"email":"a@b.c"}'; + $middleware->process( + $this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + $replay = $middleware->process( + $this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame('/users/u_1', $replay->getHeaderLine('Location')); + self::assertSame('', $replay->getHeaderLine('Set-Cookie'), 'Set-Cookie must not be replayed'); + self::assertSame('', $replay->getHeaderLine('Authorization'), 'Authorization must not be replayed'); + } + + public function testDifferentPayloadOnSameKeyReturns409(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])); + + $middleware->process( + $this->request('POST', '/users', body: '{"email":"a@b.c"}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + $response = $middleware->process( + $this->request('POST', '/users', body: '{"email":"different@b.c"}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(409, $response->getStatusCode()); + self::assertStringContainsString('different payload', (string) $response->getBody()); + } + + public function testStreamingResponseSkipsCaching(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(fn(): ResponseInterface => (new Response()) + ->withStatus(200) + ->withHeader('Content-Type', 'text/event-stream')); + + $middleware->process( + $this->request('POST', '/events', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertNull($store->get('abc'), 'streaming responses must not be cached'); + } + + public function testChunkedResponseSkipsCaching(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(fn(): ResponseInterface => (new Response()) + ->withStatus(200) + ->withHeader('Transfer-Encoding', 'chunked')); + + $middleware->process( + $this->request('POST', '/stream', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertNull($store->get('abc'), 'chunked responses must not be cached'); + } + + public function testHandlerThrowReleasesClaim(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(static function (): never { + throw new RuntimeException('boom'); + }); + + try { + $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + self::fail('Expected RuntimeException to propagate'); + } catch (RuntimeException) { + // expected + } + + self::assertNull($store->get('abc'), 'release() should drop the claim on exception'); + } + + public function testInProgressWithoutCompletionTimesOutWith409(): void + { + $store = new InMemoryStore(); + // Pre-claim the key so the middleware sees an in-progress entry on first lookup. + $store->claim('abc', hash('sha256', '{}'), 60); + + $middleware = $this->middleware($store, maxWaitMs: 20, waitIntervalMs: 5); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(200, [])); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(409, $response->getStatusCode()); + self::assertStringContainsString('still in progress', (string) $response->getBody()); + } + + public function testInProgressReleasedDuringWaitReturns409(): void + { + $store = new class implements IdempotencyStoreInterface { + private bool $firstClaim = true; + + public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse + { + if ($this->firstClaim) { + $this->firstClaim = false; + + return StoredResponse::inProgress($requestHash, 0); + } + + return null; + } + + public function complete(string $key, StoredResponse $response, int $ttlSeconds): void {} + + public function release(string $key): void {} + + public function get(string $key): ?StoredResponse + { + return null; // Simulates a claim that was released by another worker. + } + }; + + $middleware = $this->middleware($store, maxWaitMs: 20, waitIntervalMs: 5); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(200, [])); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame(409, $response->getStatusCode()); + self::assertStringContainsString('released', (string) $response->getBody()); + } + + public function testResponseBodyIsRebuiltSoDownstreamSeesContent(): void + { + // Regression guard: capturing the body in the middleware moves the + // stream pointer; the middleware must put a fresh stream on the + // response so the HTTP layer can still emit the body bytes. + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $handler = $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $handler, + ); + + self::assertSame('{"id":"u_1"}', (string) $response->getBody()); + } + + private function middleware( + IdempotencyStoreInterface $store, + string $mode = IdempotencyKeyMiddleware::MODE_OPTIONAL, + int $ttlSeconds = 60, + int $maxWaitMs = 500, + int $waitIntervalMs = 50, + ): IdempotencyKeyMiddleware { + return new IdempotencyKeyMiddleware( + store: $store, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + ttlSeconds: $ttlSeconds, + mode: $mode, + maxWaitMs: $maxWaitMs, + waitIntervalMs: $waitIntervalMs, + ); + } + + /** + * @param callable(ServerRequestInterface): ResponseInterface $callable + */ + private function handler(callable $callable): RequestHandlerInterface + { + return new class($callable) implements RequestHandlerInterface { + /** + * @param callable(ServerRequestInterface): ResponseInterface $callable + */ + public function __construct(private $callable) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return ($this->callable)($request); + } + }; + } + + /** + * @param array $headers + */ + private function request(string $method, string $path, string $body, array $headers = []): ServerRequest + { + $stream = new Stream('php://temp', 'r+'); + $stream->write($body); + $stream->rewind(); + + $request = new ServerRequest(); + $request = $request->withMethod($method)->withBody($stream); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request; + } + + /** + * @param array $payload + */ + private function jsonResponse(int $status, array $payload): ResponseInterface + { + $stream = new Stream('php://temp', 'r+'); + $stream->write((string) json_encode($payload, JSON_UNESCAPED_SLASHES)); + $stream->rewind(); + + return (new Response()) + ->withStatus($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($stream); + } +}