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
3 changes: 3 additions & 0 deletions .agent/packages/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
## Concrete classes

- `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface`
- `IdempotencyConfiguration` _(final)_ — implements `ConfigurationInterface`
- `IdempotencyKeyMiddleware` _(final)_ — implements `MiddlewareInterface`
- `InMemoryStore` _(final)_ — implements `IdempotencyStoreInterface`
- `RedisStore` _(final)_ — implements `IdempotencyStoreInterface`
Expand All @@ -35,3 +36,5 @@
- `psr/http-message`
- `psr/http-server-handler`
- `psr/http-server-middleware`
- `univeros/configuration`
- `univeros/container`
3 changes: 3 additions & 0 deletions .agent/packages/scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- `HandlerEmitter`
- `HandlerTestEmitter`
- `HistoryCommand` _(final)_
- `IdempotencySpec` _(final)_
- `ImportReceipt` _(final)_
- `InputEmitter`
- `InputFieldSpec` _(final)_
Expand Down Expand Up @@ -104,6 +105,7 @@
- `tests/Scaffold/Determinism/PersistenceEmitterDeterminismTest.php`
- `tests/Scaffold/Determinism/ScaffoldCommandDeterminismTest.php`
- `tests/Scaffold/Determinism/SdkEmitterDeterminismTest.php`
- `tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php`
- `tests/Scaffold/Emitter/ActionEmitterTest.php`
- `tests/Scaffold/Emitter/DomainStubEmitterTest.php`
- `tests/Scaffold/Emitter/EntityEmitterTest.php`
Expand Down Expand Up @@ -138,6 +140,7 @@
- `tests/Scaffold/Spec/Emitter/OperationMapperTest.php`
- `tests/Scaffold/Spec/Emitter/PathDeriverTest.php`
- `tests/Scaffold/Spec/Emitter/SchemaMapperTest.php`
- `tests/Scaffold/Spec/IdempotencyParserTest.php`
- `tests/Scaffold/Spec/ParserTest.php`
- `tests/Scaffold/Spec/PersistenceParserTest.php`
- `tests/Scaffold/Spec/PersistenceValidatorTest.php`
Expand Down
40 changes: 40 additions & 0 deletions src/Altair/Idempotency/Configuration/IdempotencyConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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\Configuration;

use Altair\Configuration\Contracts\ConfigurationInterface;
use Altair\Container\Container;
use Altair\Idempotency\Contracts\IdempotencyStoreInterface;
use Altair\Idempotency\Storage\InMemoryStore;
use Override;

/**
* Wires the idempotency primitive into the container.
*
* v1: binds `InMemoryStore` as the default `IdempotencyStoreInterface`
* implementation. Host applications swap to `ApcuStore` or `RedisStore`
* by overriding the binding in their own Configuration after this one
* has applied, or by re-binding via `Container::bind()` at boot.
*
* The TTL / scope / mode policy on a per-endpoint basis is sourced from
* the generated Action's `idempotency()` accessor (#174) — this
* Configuration only owns the storage adapter binding, not the
* middleware lifecycle.
*/
final readonly class IdempotencyConfiguration implements ConfigurationInterface
{
#[Override]
public function apply(Container $container): void
{
$container->alias(IdempotencyStoreInterface::class, InMemoryStore::class);
}
}
4 changes: 3 additions & 1 deletion src/Altair/Idempotency/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0"
"psr/http-server-middleware": "^1.0",
"univeros/configuration": "^2.0",
"univeros/container": "^2.0"
},
"suggest": {
"ext-apcu": "Required for the single-host ApcuStore adapter.",
Expand Down
42 changes: 41 additions & 1 deletion src/Altair/Scaffold/Emitter/ActionEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Altair\Scaffold\Emitter;

use Altair\Scaffold\Spec\Ast\IdempotencySpec;
use Altair\Scaffold\Spec\Ast\Spec;
use Altair\Scaffold\Templating\PhpHeader;

Expand All @@ -19,6 +20,12 @@
*
* The output extends `Altair\Http\Base\Action`; configuration is done in
* the constructor body so the action stays self-contained and discoverable.
*
* When the spec carries an `idempotency:` block, the emitted class
* exposes a static `idempotency()` accessor with the configured TTL,
* scope, and mode so the host application's
* `Altair\Idempotency\Middleware\IdempotencyKeyMiddleware` can be
* configured from spec metadata.
*/
class ActionEmitter
{
Expand All @@ -31,6 +38,7 @@ public function emit(Spec $spec): EmittedFile
$inputFqcn = $this->naming->inputFqcn($spec);
$responderFqcn = $this->naming->responderFqcn($spec);
$domainFqcn = $spec->domain->class;
$idempotencyAccessor = $this->renderIdempotencyAccessor($spec->idempotency);

$header = PhpHeader::render($namespace);
$body = <<<PHP
Expand All @@ -52,7 +60,7 @@ public function __construct()
input: \\{$inputFqcn}::class,
);
}
}
{$idempotencyAccessor}}

PHP;

Expand All @@ -69,4 +77,36 @@ private function namespaceOf(string $fqcn): string

return $pos === false ? '' : substr($fqcn, 0, $pos);
}

/**
* Renders the static `idempotency()` accessor that surfaces the
* spec's idempotency policy to the host application's middleware
* stack. Returns an empty string when the spec omits the block so
* pre-existing scaffolds stay byte-for-byte identical.
*/
private function renderIdempotencyAccessor(?IdempotencySpec $idempotency): string
{
if (!$idempotency instanceof IdempotencySpec) {
return '';
}

$ttl = var_export($idempotency->ttl, true);
$scope = var_export($idempotency->scope, true);
$mode = var_export($idempotency->mode, true);

return <<<PHP

/**
* Idempotency-Key policy for this endpoint. Consumed by the host
* application's IdempotencyKeyMiddleware via IdempotencyConfiguration.
*
* @return array{ttl: string, scope: string, mode: string}
*/
public static function idempotency(): array
{
return ['ttl' => {$ttl}, 'scope' => {$scope}, 'mode' => {$mode}];
}

PHP;
}
}
39 changes: 39 additions & 0 deletions src/Altair/Scaffold/Spec/Ast/IdempotencySpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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\Scaffold\Spec\Ast;

/**
* Optional `idempotency:` block on a Spec.
*
* When present, the scaffolder emits an `idempotency()` accessor on the
* generated Action exposing the configured TTL, scope, and mode. The
* `Altair\Idempotency\Middleware\IdempotencyKeyMiddleware` consumes
* those values at runtime via {@see \Altair\Idempotency\Configuration\IdempotencyConfiguration}.
*
* `ttl` is kept as the raw string (e.g. `"24h"`) so the same value
* round-trips byte-for-byte through `x-altair-idempotency` in
* OpenAPI (#163).
*/
final readonly class IdempotencySpec
{
public const string MODE_OPTIONAL = 'optional';

public const string MODE_REQUIRED = 'required';

public const string DEFAULT_SCOPE = 'tenant';

public function __construct(
public string $ttl,
public string $scope = self::DEFAULT_SCOPE,
public string $mode = self::MODE_OPTIONAL,
) {}
}
6 changes: 6 additions & 0 deletions src/Altair/Scaffold/Spec/Ast/Spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function __construct(
public string $sourcePath = '',
public ?PersistenceSpec $persistence = null,
public array $queue = [],
public ?IdempotencySpec $idempotency = null,
) {}

/**
Expand All @@ -47,4 +48,9 @@ public function hasPersistence(): bool
{
return $this->persistence instanceof PersistenceSpec;
}

public function hasIdempotency(): bool
{
return $this->idempotency instanceof IdempotencySpec;
}
}
24 changes: 24 additions & 0 deletions src/Altair/Scaffold/Spec/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Altair\Scaffold\Exception\SpecParseException;
use Altair\Scaffold\Spec\Ast\DomainSpec;
use Altair\Scaffold\Spec\Ast\EndpointSpec;
use Altair\Scaffold\Spec\Ast\IdempotencySpec;
use Altair\Scaffold\Spec\Ast\InputFieldSpec;
use Altair\Scaffold\Spec\Ast\OutputResponseSpec;
use Altair\Scaffold\Spec\Ast\PersistenceEntitySpec;
Expand Down Expand Up @@ -65,6 +66,29 @@ public function parseString(string $yaml, string $sourcePath = ''): Spec
? $this->parsePersistence($this->requireMap($data, 'persistence', $sourcePath))
: null,
queue: $this->parseQueue($this->optionalMap($data, 'queue')),
idempotency: isset($data['idempotency'])
? $this->parseIdempotency($this->requireMap($data, 'idempotency', $sourcePath))
: null,
);
}

/**
* @param array<string, mixed> $data
*/
private function parseIdempotency(array $data): IdempotencySpec
{
if (!isset($data['ttl']) || !\is_string($data['ttl']) || $data['ttl'] === '') {
throw new SpecParseException("'idempotency.ttl' is required and must be a non-empty string (e.g. '24h').");
}

return new IdempotencySpec(
ttl: $data['ttl'],
scope: isset($data['scope']) && \is_string($data['scope']) && $data['scope'] !== ''
? $data['scope']
: IdempotencySpec::DEFAULT_SCOPE,
mode: isset($data['mode']) && \is_string($data['mode']) && $data['mode'] !== ''
? $data['mode']
: IdempotencySpec::MODE_OPTIONAL,
);
}

Expand Down
28 changes: 28 additions & 0 deletions src/Altair/Scaffold/Spec/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Altair\Scaffold\Spec;

use Altair\Scaffold\Exception\SpecValidationException;
use Altair\Scaffold\Spec\Ast\IdempotencySpec;
use Altair\Scaffold\Spec\Ast\InputFieldSpec;
use Altair\Scaffold\Spec\Ast\PersistenceSpec;
use Altair\Scaffold\Spec\Ast\QueueDispatchSpec;
Expand Down Expand Up @@ -90,6 +91,10 @@ public function collectErrors(Spec $spec): array
array_push($errors, ...$this->validateQueue($queue));
}

if ($spec->idempotency instanceof IdempotencySpec) {
array_push($errors, ...$this->validateIdempotency($spec->idempotency));
}

return $errors;
}

Expand All @@ -102,6 +107,29 @@ public function assertValid(Spec $spec): void
}
}

/**
* @return list<string>
*/
private function validateIdempotency(IdempotencySpec $idempotency): array
{
$errors = [];

if (preg_match('/^\d+(ms|s|m|h|d)$/', $idempotency->ttl) !== 1) {
$errors[] = \sprintf("idempotency.ttl '%s' must match the pattern '<number><ms|s|m|h|d>' (e.g. '24h').", $idempotency->ttl);
}

if ($idempotency->scope === '') {
$errors[] = 'idempotency.scope must not be empty.';
}

$validModes = [IdempotencySpec::MODE_OPTIONAL, IdempotencySpec::MODE_REQUIRED];
if (!\in_array($idempotency->mode, $validModes, true)) {
$errors[] = \sprintf("idempotency.mode '%s' must be 'optional' or 'required'.", $idempotency->mode);
}

return $errors;
}

/**
* @return list<string>
*/
Expand Down
38 changes: 38 additions & 0 deletions tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Scaffold\Emitter;

use Altair\Scaffold\Emitter\ActionEmitter;
use Altair\Tests\Scaffold\Support\SpecFixture;
use PHPUnit\Framework\TestCase;

final class ActionEmitterIdempotencyTest extends TestCase
{
public function testGeneratedActionWithoutIdempotencyHasNoAccessor(): void
{
$file = (new ActionEmitter())->emit(SpecFixture::createUser());

self::assertStringNotContainsString('idempotency()', $file->contents);
}

public function testGeneratedActionExposesStaticIdempotencyAccessor(): void
{
$file = (new ActionEmitter())->emit(SpecFixture::createUserWithIdempotency());

self::assertStringContainsString('public static function idempotency()', $file->contents);
self::assertStringContainsString("'ttl' => '24h'", $file->contents);
self::assertStringContainsString("'scope' => 'tenant'", $file->contents);
self::assertStringContainsString("'mode' => 'required'", $file->contents);
}

public function testGeneratedActionWithIdempotencyIsSyntacticallyValid(): void
{
$file = (new ActionEmitter())->emit(SpecFixture::createUserWithIdempotency());

// Tokenise to confirm the generated PHP parses cleanly.
$tokens = @\token_get_all($file->contents, TOKEN_PARSE);
self::assertIsArray($tokens);
}
}
Loading
Loading