diff --git a/.agent/packages/idempotency.md b/.agent/packages/idempotency.md index 5ce4a985..cb67e0a0 100644 --- a/.agent/packages/idempotency.md +++ b/.agent/packages/idempotency.md @@ -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` @@ -35,3 +36,5 @@ - `psr/http-message` - `psr/http-server-handler` - `psr/http-server-middleware` +- `univeros/configuration` +- `univeros/container` diff --git a/.agent/packages/scaffold.md b/.agent/packages/scaffold.md index 9a13ce59..95e45572 100644 --- a/.agent/packages/scaffold.md +++ b/.agent/packages/scaffold.md @@ -31,6 +31,7 @@ - `HandlerEmitter` - `HandlerTestEmitter` - `HistoryCommand` _(final)_ +- `IdempotencySpec` _(final)_ - `ImportReceipt` _(final)_ - `InputEmitter` - `InputFieldSpec` _(final)_ @@ -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` @@ -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` diff --git a/src/Altair/Idempotency/Configuration/IdempotencyConfiguration.php b/src/Altair/Idempotency/Configuration/IdempotencyConfiguration.php new file mode 100644 index 00000000..9e4c7642 --- /dev/null +++ b/src/Altair/Idempotency/Configuration/IdempotencyConfiguration.php @@ -0,0 +1,40 @@ +alias(IdempotencyStoreInterface::class, InMemoryStore::class); + } +} diff --git a/src/Altair/Idempotency/composer.json b/src/Altair/Idempotency/composer.json index 420be34c..b0f8a168 100644 --- a/src/Altair/Idempotency/composer.json +++ b/src/Altair/Idempotency/composer.json @@ -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.", diff --git a/src/Altair/Scaffold/Emitter/ActionEmitter.php b/src/Altair/Scaffold/Emitter/ActionEmitter.php index 7c9c3944..73e7d4d6 100644 --- a/src/Altair/Scaffold/Emitter/ActionEmitter.php +++ b/src/Altair/Scaffold/Emitter/ActionEmitter.php @@ -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; @@ -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 { @@ -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 = <<ttl, true); + $scope = var_export($idempotency->scope, true); + $mode = var_export($idempotency->mode, true); + + return << {$ttl}, 'scope' => {$scope}, 'mode' => {$mode}]; + } + + PHP; + } } diff --git a/src/Altair/Scaffold/Spec/Ast/IdempotencySpec.php b/src/Altair/Scaffold/Spec/Ast/IdempotencySpec.php new file mode 100644 index 00000000..1eef383e --- /dev/null +++ b/src/Altair/Scaffold/Spec/Ast/IdempotencySpec.php @@ -0,0 +1,39 @@ +persistence instanceof PersistenceSpec; } + + public function hasIdempotency(): bool + { + return $this->idempotency instanceof IdempotencySpec; + } } diff --git a/src/Altair/Scaffold/Spec/Parser.php b/src/Altair/Scaffold/Spec/Parser.php index cc5194b8..0e229115 100644 --- a/src/Altair/Scaffold/Spec/Parser.php +++ b/src/Altair/Scaffold/Spec/Parser.php @@ -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; @@ -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 $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, ); } diff --git a/src/Altair/Scaffold/Spec/Validator.php b/src/Altair/Scaffold/Spec/Validator.php index 5129684f..782ddf88 100644 --- a/src/Altair/Scaffold/Spec/Validator.php +++ b/src/Altair/Scaffold/Spec/Validator.php @@ -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; @@ -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; } @@ -102,6 +107,29 @@ public function assertValid(Spec $spec): void } } + /** + * @return list + */ + 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 '' (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 */ diff --git a/tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php b/tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php new file mode 100644 index 00000000..d44db8cc --- /dev/null +++ b/tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/tests/Scaffold/Spec/IdempotencyParserTest.php b/tests/Scaffold/Spec/IdempotencyParserTest.php new file mode 100644 index 00000000..ed4f582c --- /dev/null +++ b/tests/Scaffold/Spec/IdempotencyParserTest.php @@ -0,0 +1,203 @@ +parseString($yaml); + + self::assertTrue($spec->hasIdempotency()); + self::assertInstanceOf(IdempotencySpec::class, $spec->idempotency); + self::assertSame('24h', $spec->idempotency->ttl); + self::assertSame('tenant', $spec->idempotency->scope); + self::assertSame('required', $spec->idempotency->mode); + } + + public function testIdempotencyDefaultsScopeAndMode(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + ttl: 1h + YAML; + + $spec = (new Parser())->parseString($yaml); + + self::assertInstanceOf(IdempotencySpec::class, $spec->idempotency); + self::assertSame('tenant', $spec->idempotency->scope); + self::assertSame('optional', $spec->idempotency->mode); + } + + public function testIdempotencyMissingTtlRaises(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + scope: tenant + YAML; + + $this->expectException(SpecParseException::class); + $this->expectExceptionMessage('idempotency.ttl'); + (new Parser())->parseString($yaml); + } + + public function testIdempotencyAbsentLeavesNullAndHasIdempotencyFalse(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + YAML; + + $spec = (new Parser())->parseString($yaml); + + self::assertFalse($spec->hasIdempotency()); + self::assertNull($spec->idempotency); + } + + public function testValidatorRejectsBadTtlPattern(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + ttl: forever + YAML; + + $spec = (new Parser())->parseString($yaml); + + $errors = (new Validator())->collectErrors($spec); + + self::assertNotEmpty($errors); + $matched = array_filter($errors, static fn(string $e): bool => str_contains($e, 'idempotency.ttl')); + self::assertNotEmpty($matched); + } + + public function testValidatorRejectsBadMode(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + ttl: 24h + mode: maybe + YAML; + + $spec = (new Parser())->parseString($yaml); + + $errors = (new Validator())->collectErrors($spec); + + $matched = array_filter($errors, static fn(string $e): bool => str_contains($e, 'idempotency.mode')); + self::assertNotEmpty($matched); + } + + public function testValidatorAcceptsAllTtlUnits(): void + { + $validator = new Validator(); + $parser = new Parser(); + foreach (['100ms', '30s', '5m', '24h', '7d'] as $ttl) { + $yaml = <<parseString($yaml); + $errors = $validator->collectErrors($spec); + $ttlErrors = array_filter($errors, static fn(string $e): bool => str_contains($e, 'idempotency.ttl')); + self::assertSame([], $ttlErrors, sprintf("ttl='%s' should be accepted", $ttl)); + } + } + + public function testValidatorAcceptsValidBlock(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + ttl: 24h + scope: tenant + mode: required + YAML; + + $spec = (new Parser())->parseString($yaml); + + (new Validator())->assertValid($spec); + + $this->expectNotToPerformAssertions(); + } + + public function testValidatorRaisesAggregatedExceptionOnFailure(): void + { + $yaml = <<<'YAML' + endpoint: + method: post + path: /users + tags: [] + domain: + class: App\User\CreateUser + idempotency: + ttl: bogus + mode: nope + YAML; + + $spec = (new Parser())->parseString($yaml); + + $this->expectException(SpecValidationException::class); + (new Validator())->assertValid($spec); + } +} diff --git a/tests/Scaffold/Support/SpecFixture.php b/tests/Scaffold/Support/SpecFixture.php index 14469303..063e22e8 100644 --- a/tests/Scaffold/Support/SpecFixture.php +++ b/tests/Scaffold/Support/SpecFixture.php @@ -6,6 +6,7 @@ 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; @@ -60,6 +61,24 @@ public static function createUserWithQueue(): Spec ); } + public static function createUserWithIdempotency(): Spec + { + $base = self::createUser(); + + return new Spec( + endpoint: $base->endpoint, + inputs: $base->inputs, + outputs: $base->outputs, + domain: $base->domain, + sourcePath: $base->sourcePath, + idempotency: new IdempotencySpec( + ttl: '24h', + scope: 'tenant', + mode: IdempotencySpec::MODE_REQUIRED, + ), + ); + } + public static function createUserWithPersistence(): Spec { $base = self::createUser();