From 5d33e5ba6ced0c845c7a9d5e9d42d45bdccf72ff Mon Sep 17 00:00:00 2001 From: EddyLogipro Date: Mon, 15 Jun 2026 16:14:01 +0200 Subject: [PATCH 1/2] K#3532 Contrat --- README.md | 4 +- docs/openapi/am-client-v1.yaml | 6 ++ .../Console/Command/CallbackSendCommand.php | 4 +- .../InMemory/LoggingCreateInstanceHandler.php | 5 +- .../CreateInstanceHandlerInterface.php | 3 +- src/Core/Dto/CreateInstanceHandlerResult.php | 21 +++++ src/Core/Dto/OrchestrationCallbackRequest.php | 34 ++++++- .../OrchestrationCommandProcessor.php | 21 +++-- .../Dto/OrchestrationCallbackRequestTest.php | 71 +++++++++++++++ tests/Unit/Http/AmApiClientTest.php | 22 +++++ .../OrchestrationCommandProcessorTest.php | 90 +++++++++++++++++++ 11 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 src/Core/Dto/CreateInstanceHandlerResult.php create mode 100644 tests/Unit/Dto/OrchestrationCallbackRequestTest.php create mode 100644 tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php diff --git a/README.md b/README.md index 6bbae1f..9b908f6 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,15 @@ am_driver: ```php use ApplicationManagerTools\AmDriver\Core\Contract\CreateInstanceHandlerInterface; +use ApplicationManagerTools\AmDriver\Core\Dto\CreateInstanceHandlerResult; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; final class MyCreateInstanceHandler implements CreateInstanceHandlerInterface { - public function handle(OrchestrationCommand $command): void + public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult { // provision tenant / DB / storage + return new CreateInstanceHandlerResult('https://tenant.example/login'); } } ``` diff --git a/docs/openapi/am-client-v1.yaml b/docs/openapi/am-client-v1.yaml index e227632..d417ac1 100644 --- a/docs/openapi/am-client-v1.yaml +++ b/docs/openapi/am-client-v1.yaml @@ -108,6 +108,7 @@ paths: value: idempotencyKey: am_ins_10000000-0000-4000-8000-000000000001:create_instance:v1 status: SUCCEEDED + location: https://tenant.example/login failed: value: idempotencyKey: am_ins_10000000-0000-4000-8000-000000000001:create_instance:v1 @@ -178,3 +179,8 @@ components: message: type: string description: Détail d’échec (optionnel). + location: + type: string + format: uri + nullable: true + description: URL d'accès tenant (CREATE_INSTANCE SUCCEEDED). diff --git a/src/Bridge/Console/Command/CallbackSendCommand.php b/src/Bridge/Console/Command/CallbackSendCommand.php index d83390f..3429a34 100644 --- a/src/Bridge/Console/Command/CallbackSendCommand.php +++ b/src/Bridge/Console/Command/CallbackSendCommand.php @@ -26,7 +26,8 @@ protected function configure(): void ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-Orchestration-Callback-Token') ->addOption('idempotency-key', null, InputOption::VALUE_REQUIRED, 'idempotencyKey') ->addOption('status', null, InputOption::VALUE_REQUIRED, 'SUCCEEDED|FAILED|RETRYABLE_FAILURE', 'SUCCEEDED') - ->addOption('message', null, InputOption::VALUE_OPTIONAL, 'Optional message'); + ->addOption('message', null, InputOption::VALUE_OPTIONAL, 'Optional message') + ->addOption('location', null, InputOption::VALUE_OPTIONAL, 'Optional tenant access URL (CREATE_INSTANCE SUCCEEDED)'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -50,6 +51,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int (string) $input->getOption('idempotency-key'), CallbackStatus::fromString((string) $input->getOption('status')), $input->getOption('message') ? (string) $input->getOption('message') : null, + $input->getOption('location') ? (string) $input->getOption('location') : null, ); $response = $client->reportOrchestrationCallback($request); diff --git a/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php b/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php index 10366b3..ddf19fc 100644 --- a/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php +++ b/src/Core/Cli/InMemory/LoggingCreateInstanceHandler.php @@ -5,6 +5,7 @@ namespace ApplicationManagerTools\AmDriver\Core\Cli\InMemory; use ApplicationManagerTools\AmDriver\Core\Contract\CreateInstanceHandlerInterface; +use ApplicationManagerTools\AmDriver\Core\Dto\CreateInstanceHandlerResult; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; final class LoggingCreateInstanceHandler implements CreateInstanceHandlerInterface @@ -17,8 +18,10 @@ public function __construct(CommandCallLog $log) $this->log = $log; } - public function handle(OrchestrationCommand $command): void + public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult { $this->log->add('CREATE_INSTANCE', $command); + + return new CreateInstanceHandlerResult(); } } diff --git a/src/Core/Contract/CreateInstanceHandlerInterface.php b/src/Core/Contract/CreateInstanceHandlerInterface.php index e15e99b..282a873 100644 --- a/src/Core/Contract/CreateInstanceHandlerInterface.php +++ b/src/Core/Contract/CreateInstanceHandlerInterface.php @@ -4,9 +4,10 @@ namespace ApplicationManagerTools\AmDriver\Core\Contract; +use ApplicationManagerTools\AmDriver\Core\Dto\CreateInstanceHandlerResult; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; interface CreateInstanceHandlerInterface { - public function handle(OrchestrationCommand $command): void; + public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult; } diff --git a/src/Core/Dto/CreateInstanceHandlerResult.php b/src/Core/Dto/CreateInstanceHandlerResult.php new file mode 100644 index 0000000..2dd69f0 --- /dev/null +++ b/src/Core/Dto/CreateInstanceHandlerResult.php @@ -0,0 +1,21 @@ +instanceLocation = $instanceLocation; + } + + public function instanceLocation(): ?string + { + return $this->instanceLocation; + } +} diff --git a/src/Core/Dto/OrchestrationCallbackRequest.php b/src/Core/Dto/OrchestrationCallbackRequest.php index c396046..30c25f5 100644 --- a/src/Core/Dto/OrchestrationCallbackRequest.php +++ b/src/Core/Dto/OrchestrationCallbackRequest.php @@ -6,6 +6,7 @@ use ApplicationManagerTools\AmDriver\Core\Orchestration\CallbackStatus; use ApplicationManagerTools\AmDriver\Core\Validation\JsonPayloadValidator; +use InvalidArgumentException; final class OrchestrationCallbackRequest { @@ -18,11 +19,19 @@ final class OrchestrationCallbackRequest /** @var string|null */ private $message; - public function __construct(string $idempotencyKey, CallbackStatus $status, ?string $message = null) - { + /** @var string|null */ + private $location; + + public function __construct( + string $idempotencyKey, + CallbackStatus $status, + ?string $message = null, + ?string $location = null + ) { $this->idempotencyKey = $idempotencyKey; $this->status = $status; $this->message = $message; + $this->location = $location; } /** @@ -35,11 +44,24 @@ public static function fromArray(array $data): self JsonPayloadValidator::requireNonEmptyString($data, 'status'); $message = isset($data['message']) && \is_string($data['message']) ? $data['message'] : null; + $location = null; + if (\array_key_exists('location', $data)) { + if (null !== $data['location'] && !\is_string($data['location'])) { + throw new InvalidArgumentException('location must be a string URI or null.'); + } + if (\is_string($data['location']) && '' !== $data['location']) { + if (false === filter_var($data['location'], FILTER_VALIDATE_URL)) { + throw new InvalidArgumentException('location must be a valid URI.'); + } + $location = $data['location']; + } + } return new self( (string) $data['idempotencyKey'], CallbackStatus::fromString((string) $data['status']), $message, + $location, ); } @@ -58,6 +80,11 @@ public function message(): ?string return $this->message; } + public function location(): ?string + { + return $this->location; + } + /** * @return array */ @@ -70,6 +97,9 @@ public function toArray(): array if (null !== $this->message) { $payload['message'] = $this->message; } + if (null !== $this->location) { + $payload['location'] = $this->location; + } return $payload; } diff --git a/src/Core/Orchestration/OrchestrationCommandProcessor.php b/src/Core/Orchestration/OrchestrationCommandProcessor.php index 301e520..bbc7a8c 100644 --- a/src/Core/Orchestration/OrchestrationCommandProcessor.php +++ b/src/Core/Orchestration/OrchestrationCommandProcessor.php @@ -7,6 +7,7 @@ use ApplicationManagerTools\AmDriver\Core\Contract\CreateInstanceHandlerInterface; use ApplicationManagerTools\AmDriver\Core\Contract\StartInstanceHandlerInterface; use ApplicationManagerTools\AmDriver\Core\Contract\StopInstanceHandlerInterface; +use ApplicationManagerTools\AmDriver\Core\Dto\CreateInstanceHandlerResult; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCallbackRequest; use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; use ApplicationManagerTools\AmDriver\Core\Exception\HandlerFailedException; @@ -55,9 +56,10 @@ public function process(OrchestrationCommand $command): array return ['httpStatus' => 200, 'alreadyProcessed' => true]; } + $createResult = null; try { if ($command->operation()->isCreate()) { - $this->createHandler->handle($command); + $createResult = $this->createHandler->handle($command); } elseif ($command->operation()->isStop()) { $this->stopHandler->handle($command); } elseif ($command->operation()->isStart()) { @@ -86,15 +88,24 @@ public function process(OrchestrationCommand $command): array } $this->idempotencyStore->remember($command->idempotencyKey()); - $this->reportCallback($command, CallbackStatus::succeeded(), null); + $this->reportCallback( + $command, + CallbackStatus::succeeded(), + null, + $createResult instanceof CreateInstanceHandlerResult ? $createResult->instanceLocation() : null, + ); return ['httpStatus' => 200, 'alreadyProcessed' => false]; } - private function reportCallback(OrchestrationCommand $command, CallbackStatus $status, ?string $message): void - { + private function reportCallback( + OrchestrationCommand $command, + CallbackStatus $status, + ?string $message, + ?string $location = null, + ): void { $this->amApiClient->reportOrchestrationCallback( - new OrchestrationCallbackRequest($command->idempotencyKey(), $status, $message), + new OrchestrationCallbackRequest($command->idempotencyKey(), $status, $message, $location), ); } } diff --git a/tests/Unit/Dto/OrchestrationCallbackRequestTest.php b/tests/Unit/Dto/OrchestrationCallbackRequestTest.php new file mode 100644 index 0000000..37e9ff9 --- /dev/null +++ b/tests/Unit/Dto/OrchestrationCallbackRequestTest.php @@ -0,0 +1,71 @@ +toArray(); + + // Assert + self::assertArrayNotHasKey('location', $array); + } + + public function testToArrayIncludesLocationWhenSet(): void + { + // Arrange + $request = new OrchestrationCallbackRequest( + 'idem-1', + CallbackStatus::succeeded(), + null, + 'https://tenant.example/login', + ); + + // Act + $array = $request->toArray(); + + // Assert + self::assertSame('https://tenant.example/login', $array['location']); + } + + public function testFromArrayAcceptsOptionalLocation(): void + { + // Arrange + $data = [ + 'idempotencyKey' => 'idem-1', + 'status' => 'SUCCEEDED', + 'location' => 'https://tenant.example/login', + ]; + + // Act + $request = OrchestrationCallbackRequest::fromArray($data); + + // Assert + self::assertSame('https://tenant.example/login', $request->location()); + } + + public function testFromArrayRejectsInvalidLocationUri(): void + { + // Arrange + $this->expectException(InvalidArgumentException::class); + + // Act + OrchestrationCallbackRequest::fromArray([ + 'idempotencyKey' => 'idem-1', + 'status' => 'SUCCEEDED', + 'location' => 'not-a-uri', + ]); + } +} diff --git a/tests/Unit/Http/AmApiClientTest.php b/tests/Unit/Http/AmApiClientTest.php index 2c94204..7ccd04f 100644 --- a/tests/Unit/Http/AmApiClientTest.php +++ b/tests/Unit/Http/AmApiClientTest.php @@ -54,6 +54,28 @@ public function testReportCallbackUsesCallbackToken(): void self::assertSame('secret-cb', $this->headerValue($recording->options, 'X-Orchestration-Callback-Token')); } + public function testReportCallbackSerializesLocationInJsonBody(): void + { + // Arrange + $recording = new RecordingHttpClient(); + $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-cons', 'secret-cb')); + + // Act + $api->reportOrchestrationCallback(new OrchestrationCallbackRequest( + 'idem-key', + CallbackStatus::succeeded(), + null, + 'https://tenant.example/login', + )); + + // Assert + $body = $recording->options['body'] ?? null; + self::assertIsString($body); + /** @var array $json */ + $json = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + self::assertSame('https://tenant.example/login', $json['location'] ?? null); + } + /** * @param array $options */ diff --git a/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php b/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php new file mode 100644 index 0000000..f98c9f0 --- /dev/null +++ b/tests/Unit/Orchestration/OrchestrationCommandProcessorTest.php @@ -0,0 +1,90 @@ + $payload */ + $payload = json_decode( + (string) file_get_contents(dirname(__DIR__, 2).'/fixtures/orchestration-command-create.json'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + $command = OrchestrationCommand::fromArray($payload); + $callbacks = []; + $processor = new OrchestrationCommandProcessor( + new class implements CreateInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): CreateInstanceHandlerResult + { + return new CreateInstanceHandlerResult('https://tenant.example/login'); + } + }, + new class implements StopInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): void + { + } + }, + new class implements StartInstanceHandlerInterface { + public function handle(OrchestrationCommand $command): void + { + } + }, + new class implements IdempotencyStoreInterface { + public function has(string $idempotencyKey): bool + { + return false; + } + + public function remember(string $idempotencyKey): void + { + } + }, + new class($callbacks) implements AmApiClientInterface { + /** @var list> */ + private $callbacks; + + /** @param list> $callbacks */ + public function __construct(array &$callbacks) + { + $this->callbacks = &$callbacks; + } + + public function pushConsumption($event): array + { + return ['statusCode' => 202, 'body' => '']; + } + + public function reportOrchestrationCallback($request): array + { + $this->callbacks[] = $request->toArray(); + + return ['statusCode' => 202, 'body' => '']; + } + }, + ); + + // Act + $processor->process($command); + + // Assert + self::assertCount(1, $callbacks); + self::assertSame('https://tenant.example/login', $callbacks[0]['location'] ?? null); + self::assertSame('SUCCEEDED', $callbacks[0]['status'] ?? null); + } +} From eacf03309af1035d165e4a9377d321167686b98a Mon Sep 17 00:00:00 2001 From: EddyLogipro Date: Mon, 15 Jun 2026 16:28:16 +0200 Subject: [PATCH 2/2] K#3532 correction pipeline --- src/Core/Orchestration/OrchestrationCommandProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Orchestration/OrchestrationCommandProcessor.php b/src/Core/Orchestration/OrchestrationCommandProcessor.php index bbc7a8c..c8338ba 100644 --- a/src/Core/Orchestration/OrchestrationCommandProcessor.php +++ b/src/Core/Orchestration/OrchestrationCommandProcessor.php @@ -102,7 +102,7 @@ private function reportCallback( OrchestrationCommand $command, CallbackStatus $status, ?string $message, - ?string $location = null, + ?string $location = null ): void { $this->amApiClient->reportOrchestrationCallback( new OrchestrationCallbackRequest($command->idempotencyKey(), $status, $message, $location),