Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agent/packages/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@

## Concrete classes

- `ActionAwareIdempotencyMiddleware` _(final)_ — implements `MiddlewareInterface`
- `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface`
- `IdempotencyConfiguration` _(final)_ — implements `ConfigurationInterface`
- `IdempotencyKeyMiddleware` _(final)_ — implements `MiddlewareInterface`
- `InMemoryStore` _(final)_ — implements `IdempotencyStoreInterface`
- `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`
Expand Down
21 changes: 17 additions & 4 deletions docs/packages/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions src/Altair/Idempotency/Hash/TtlParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Hash;

use Altair\Idempotency\Exception\IdempotencyException;

/**
* Translates a TTL string carried in a spec / OpenAPI extension into
* seconds for the storage adapter. Same pattern the spec validator
* enforces: `<number><ms|s|m|h|d>`.
*
* 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 '<number><ms|s|m|h|d>' (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];
}
}
111 changes: 111 additions & 0 deletions src/Altair/Idempotency/Middleware/ActionAwareIdempotencyMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Middleware;

use Altair\Idempotency\Contracts\IdempotencyStoreInterface;
use Altair\Idempotency\Hash\RequestBodyHasher;
use Altair\Idempotency\Hash\TtlParser;
use Override;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Auto-wires the idempotency primitive from the resolved Action's
* spec metadata.
*
* Reads the resolved Action (via the request attribute that
* `DispatcherMiddleware` publishes; default attribute name
* `altair:http:action` matches `Altair\Http\Contracts\MiddlewareInterface::ATTRIBUTE_ACTION`)
* and, if the Action exposes a static `idempotency()` accessor (the
* one #174's `ActionEmitter` generates), constructs a per-request
* {@see IdempotencyKeyMiddleware} configured from that policy and
* delegates to it.
*
* When no Action is on the request, or when the Action has no
* `idempotency()` method, the middleware passes the request through
* unchanged — so adding it to the stack is safe for endpoints that
* have not opted into the primitive.
*
* The dependency on `univeros/http` is intentionally avoided by
* configuring the attribute name via the constructor; the default
* mirrors the value `univeros/http` ships so most hosts need no
* argument at all.
*/
final readonly class ActionAwareIdempotencyMiddleware implements MiddlewareInterface
{
/** Matches `Altair\Http\Contracts\MiddlewareInterface::ATTRIBUTE_ACTION`. */
public const string DEFAULT_ACTION_ATTRIBUTE = 'altair:http:action';

public function __construct(
private IdempotencyStoreInterface $store,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
private RequestBodyHasher $hasher = new RequestBodyHasher(),
private TtlParser $ttlParser = new TtlParser(),
private string $actionAttribute = self::DEFAULT_ACTION_ATTRIBUTE,
) {}

#[Override]
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$policy = $this->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']];
}
}
78 changes: 78 additions & 0 deletions tests/Idempotency/Hash/TtlParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Idempotency\Hash;

use Altair\Idempotency\Exception\IdempotencyException;
use Altair\Idempotency\Hash\TtlParser;
use PHPUnit\Framework\TestCase;

final class TtlParserTest extends TestCase
{
public function testSecondsUnit(): void
{
self::assertSame(30, (new TtlParser())->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'));
}
}
Loading
Loading