diff --git a/.agent/packages/idempotency.md b/.agent/packages/idempotency.md index cb67e0a..74098b9 100644 --- a/.agent/packages/idempotency.md +++ b/.agent/packages/idempotency.md @@ -13,6 +13,7 @@ ## Concrete classes +- `ActionAwareIdempotencyMiddleware` _(final)_ — implements `MiddlewareInterface` - `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface` - `IdempotencyConfiguration` _(final)_ — implements `ConfigurationInterface` - `IdempotencyKeyMiddleware` _(final)_ — implements `MiddlewareInterface` @@ -20,10 +21,13 @@ - `RedisStore` _(final)_ — implements `IdempotencyStoreInterface` - `RequestBodyHasher` - `StoredResponse` _(final)_ +- `TtlParser` _(final)_ ## Tests as documentation - `tests/Idempotency/Hash/RequestBodyHasherTest.php` +- `tests/Idempotency/Hash/TtlParserTest.php` +- `tests/Idempotency/Middleware/ActionAwareIdempotencyMiddlewareTest.php` - `tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php` - `tests/Idempotency/Storage/ApcuStoreTest.php` - `tests/Idempotency/Storage/InMemoryStoreTest.php` diff --git a/docs/packages/idempotency.md b/docs/packages/idempotency.md index 703897c..e5d6a25 100644 --- a/docs/packages/idempotency.md +++ b/docs/packages/idempotency.md @@ -90,7 +90,7 @@ public static function idempotency(): array ### 3. Wire the middleware (host) -In the host application's container Configuration chain, register `IdempotencyConfiguration` so `IdempotencyStoreInterface` resolves: +Two lines. First, register `IdempotencyConfiguration` in the container chain so `IdempotencyStoreInterface` resolves: ```php // config/configurations.php @@ -111,9 +111,24 @@ $container->bind(\Altair\Idempotency\Contracts\IdempotencyStoreInterface::class) }); ``` -Then add `IdempotencyKeyMiddleware` to the middleware pipeline ahead of input validation: +Second, add `ActionAwareIdempotencyMiddleware` to the middleware pipeline **after** `DispatcherMiddleware` (which publishes the resolved Action on the request) and **before** `ActionMiddleware` (which invokes it): ```php +$middleware->add(new \Altair\Idempotency\Middleware\ActionAwareIdempotencyMiddleware( + store: $container->get(\Altair\Idempotency\Contracts\IdempotencyStoreInterface::class), + responseFactory: $container->get(\Psr\Http\Message\ResponseFactoryInterface::class), + streamFactory: $container->get(\Psr\Http\Message\StreamFactoryInterface::class), +)); +``` + +That's the entire host wiring. The middleware reads each request's resolved Action via the `altair:http:action` attribute, looks for the static `idempotency()` accessor the scaffolder emits when a spec carries the `idempotency:` block, and configures a per-request `IdempotencyKeyMiddleware` with the spec's TTL and mode. Endpoints without the block see no behaviour change — the middleware passes them through. + +#### Manual wiring (escape hatch) + +For endpoints that need a different policy than the spec declares — say, forcing `mode: required` globally even when individual specs say `optional` — use `IdempotencyKeyMiddleware` directly: + +```php +// Manual per-route wiring — only when you need to override the spec-driven policy. $middleware->add(new \Altair\Idempotency\Middleware\IdempotencyKeyMiddleware( store: $container->get(\Altair\Idempotency\Contracts\IdempotencyStoreInterface::class), responseFactory: $container->get(\Psr\Http\Message\ResponseFactoryInterface::class), @@ -123,8 +138,6 @@ $middleware->add(new \Altair\Idempotency\Middleware\IdempotencyKeyMiddleware( )); ``` -For per-endpoint policy (different TTL / mode per route), the host's middleware factory reads the resolved Action's `idempotency()` accessor — Univeros's `DispatcherMiddleware` exposes the action via the `MiddlewareInterface::ATTRIBUTE_ACTION` request attribute, so the idempotency middleware can be a thin wrapper that introspects the action and configures itself per request. - ### 4. Use it ```bash diff --git a/src/Altair/Idempotency/Hash/TtlParser.php b/src/Altair/Idempotency/Hash/TtlParser.php new file mode 100644 index 0000000..97b5282 --- /dev/null +++ b/src/Altair/Idempotency/Hash/TtlParser.php @@ -0,0 +1,53 @@ +`. + * + * Pure utility. No dependencies, no clock. Deterministic. + */ +final readonly class TtlParser +{ + private const array MULTIPLIERS = [ + 'ms' => 0, // milliseconds round down to 0 seconds at the storage layer + 's' => 1, + 'm' => 60, + 'h' => 3_600, + 'd' => 86_400, + ]; + + public function toSeconds(string $ttl): int + { + if (preg_match('/^(\d+)(ms|s|m|h|d)$/', $ttl, $match) !== 1) { + throw new IdempotencyException(\sprintf( + "TTL '%s' must match '' (e.g. '24h', '500ms', '7d').", + $ttl, + )); + } + + $value = (int) $match[1]; + $unit = $match[2]; + + if ($unit === 'ms') { + // Storage TTLs are in seconds; sub-second TTLs are meaningful for + // the spec but always round up to at least 1 second on the wire. + return $value > 0 ? max(1, (int) ceil($value / 1000)) : 0; + } + + return $value * self::MULTIPLIERS[$unit]; + } +} diff --git a/src/Altair/Idempotency/Middleware/ActionAwareIdempotencyMiddleware.php b/src/Altair/Idempotency/Middleware/ActionAwareIdempotencyMiddleware.php new file mode 100644 index 0000000..092d4b8 --- /dev/null +++ b/src/Altair/Idempotency/Middleware/ActionAwareIdempotencyMiddleware.php @@ -0,0 +1,111 @@ +resolvePolicy($request); + if ($policy === null) { + return $handler->handle($request); + } + + $middleware = new IdempotencyKeyMiddleware( + store: $this->store, + responseFactory: $this->responseFactory, + streamFactory: $this->streamFactory, + ttlSeconds: $this->ttlParser->toSeconds($policy['ttl']), + mode: $policy['mode'], + hasher: $this->hasher, + ); + + return $middleware->process($request, $handler); + } + + /** + * @return ?array{ttl: string, scope: string, mode: string} + */ + private function resolvePolicy(ServerRequestInterface $request): ?array + { + $action = $request->getAttribute($this->actionAttribute); + if (!\is_object($action)) { + return null; + } + + if (!method_exists($action, 'idempotency')) { + return null; + } + + /** @var mixed $policy */ + $policy = $action::idempotency(); + if (!\is_array($policy)) { + return null; + } + + if (!isset($policy['ttl'], $policy['scope'], $policy['mode'])) { + return null; + } + + if (!\is_string($policy['ttl']) || !\is_string($policy['scope']) || !\is_string($policy['mode'])) { + return null; + } + + return ['ttl' => $policy['ttl'], 'scope' => $policy['scope'], 'mode' => $policy['mode']]; + } +} diff --git a/tests/Idempotency/Hash/TtlParserTest.php b/tests/Idempotency/Hash/TtlParserTest.php new file mode 100644 index 0000000..d386ca2 --- /dev/null +++ b/tests/Idempotency/Hash/TtlParserTest.php @@ -0,0 +1,78 @@ +toSeconds('30s')); + } + + public function testMinutesUnit(): void + { + self::assertSame(300, (new TtlParser())->toSeconds('5m')); + } + + public function testHoursUnit(): void + { + self::assertSame(86_400, (new TtlParser())->toSeconds('24h')); + } + + public function testDaysUnit(): void + { + self::assertSame(7 * 86_400, (new TtlParser())->toSeconds('7d')); + } + + public function testMillisecondsRoundUpToOneSecond(): void + { + // Sub-second TTLs are accepted by the spec validator but storage + // adapters work in seconds; round up so a small positive TTL never + // collapses to zero. + self::assertSame(1, (new TtlParser())->toSeconds('500ms')); + self::assertSame(1, (new TtlParser())->toSeconds('999ms')); + } + + public function testMillisecondsAboveOneSecondRoundUp(): void + { + self::assertSame(2, (new TtlParser())->toSeconds('1500ms')); + self::assertSame(3, (new TtlParser())->toSeconds('2001ms')); + } + + public function testZeroMillisecondsCollapsesToZero(): void + { + self::assertSame(0, (new TtlParser())->toSeconds('0ms')); + } + + public function testMalformedRejected(): void + { + $this->expectException(IdempotencyException::class); + $this->expectExceptionMessage('must match'); + (new TtlParser())->toSeconds('forever'); + } + + public function testUnknownUnitRejected(): void + { + $this->expectException(IdempotencyException::class); + (new TtlParser())->toSeconds('5y'); + } + + public function testNegativeNumberRejected(): void + { + $this->expectException(IdempotencyException::class); + (new TtlParser())->toSeconds('-1h'); + } + + public function testZeroSecondsAccepted(): void + { + // Zero seconds is degenerate but valid; storage adapters treat it + // as "expire immediately" which is a reasonable interpretation. + self::assertSame(0, (new TtlParser())->toSeconds('0s')); + } +} diff --git a/tests/Idempotency/Middleware/ActionAwareIdempotencyMiddlewareTest.php b/tests/Idempotency/Middleware/ActionAwareIdempotencyMiddlewareTest.php new file mode 100644 index 0000000..ff221fd --- /dev/null +++ b/tests/Idempotency/Middleware/ActionAwareIdempotencyMiddlewareTest.php @@ -0,0 +1,236 @@ +middleware($store); + + $response = $middleware->process( + $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])), + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertNull($store->get('abc'), 'no Action attribute → no caching'); + } + + public function testPassesThroughWhenActionLacksIdempotencyAccessor(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + + $action = new class { + // No idempotency() method — pass-through. + }; + $request = $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']) + ->withAttribute(ActionAwareIdempotencyMiddleware::DEFAULT_ACTION_ATTRIBUTE, $action); + + $response = $middleware->process( + $request, + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])), + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertNull($store->get('abc')); + } + + public function testDelegatesToIdempotencyKeyMiddlewareWhenActionExposesPolicy(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + + $action = $this->actionWithPolicy('24h', 'tenant', 'optional'); + $callCount = 0; + $handler = $this->handler(function () use (&$callCount): ResponseInterface { + $callCount++; + return $this->jsonResponse(201, ['id' => 'u_1']); + }); + + $body = '{"email":"a@b.c"}'; + $first = $middleware->process( + $this->withAction($this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']), $action), + $handler, + ); + $second = $middleware->process( + $this->withAction($this->request('POST', '/users', body: $body, headers: ['Idempotency-Key' => 'abc']), $action), + $handler, + ); + + self::assertSame(1, $callCount, 'handler should run only once'); + self::assertSame(201, $first->getStatusCode()); + self::assertSame(201, $second->getStatusCode()); + self::assertSame('true', $second->getHeaderLine(IdempotencyKeyMiddleware::HEADER_REPLAYED)); + + $stored = $store->get('abc'); + self::assertInstanceOf(StoredResponse::class, $stored); + self::assertFalse($stored->inProgress); + } + + public function testRequiredModeFromActionRejectsMissingHeader(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + $action = $this->actionWithPolicy('24h', 'tenant', 'required'); + + $response = $middleware->process( + $this->withAction($this->request('POST', '/users', body: '{}'), $action), + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, [])), + ); + + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('Idempotency-Key header required', (string) $response->getBody()); + } + + public function testCustomAttributeName(): void + { + $store = new InMemoryStore(); + $middleware = new ActionAwareIdempotencyMiddleware( + store: $store, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + actionAttribute: 'custom:action', + ); + + $action = $this->actionWithPolicy('24h', 'tenant', 'optional'); + $request = $this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']) + ->withAttribute('custom:action', $action); + + $response = $middleware->process( + $request, + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, ['id' => 'u_1'])), + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertNotNull($store->get('abc')); + } + + public function testIgnoresMalformedPolicy(): void + { + $store = new InMemoryStore(); + $middleware = $this->middleware($store); + + $action = new class { + public static function idempotency(): array + { + return ['ttl' => 123]; // numeric, not string — invalid shape + } + }; + + $response = $middleware->process( + $this->withAction($this->request('POST', '/users', body: '{}', headers: ['Idempotency-Key' => 'abc']), $action), + $this->handler(fn(): ResponseInterface => $this->jsonResponse(201, [])), + ); + + // Malformed policy → pass through instead of crashing. + self::assertSame(201, $response->getStatusCode()); + self::assertNull($store->get('abc')); + } + + private function middleware(InMemoryStore $store): ActionAwareIdempotencyMiddleware + { + return new ActionAwareIdempotencyMiddleware( + store: $store, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + ); + } + + private function actionWithPolicy(string $ttl, string $scope, string $mode): object + { + return new class($ttl, $scope, $mode) { + public static string $ttl; + + public static string $scope; + + public static string $mode; + + public function __construct(string $ttl, string $scope, string $mode) + { + self::$ttl = $ttl; + self::$scope = $scope; + self::$mode = $mode; + } + + public static function idempotency(): array + { + return ['ttl' => self::$ttl, 'scope' => self::$scope, 'mode' => self::$mode]; + } + }; + } + + private function withAction(ServerRequest $request, object $action): ServerRequest + { + return $request->withAttribute(ActionAwareIdempotencyMiddleware::DEFAULT_ACTION_ATTRIBUTE, $action); + } + + /** + * @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); + } +}