From 90c87899a2d6583eca897c931e065b02732b6703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Tue, 18 Aug 2026 18:25:08 +0200 Subject: [PATCH] refactor(console): encapsulate interactive search and autocompletion in ClientConsoleResolver --- .../RegenerateClientSecretCommand.php | 141 +++-------------- .../Commands/ToggleClientStatusCommand.php | 149 ++++-------------- .../Services/ClientConsoleResolver.php | 135 ++++++++++++++++ 3 files changed, 190 insertions(+), 235 deletions(-) create mode 100644 src/Presentation/Console/Services/ClientConsoleResolver.php diff --git a/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php index 9fa7152..3723842 100644 --- a/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php +++ b/src/Presentation/Console/Commands/RegenerateClientSecretCommand.php @@ -7,23 +7,16 @@ namespace Presentation\Console\Commands; use Application\Security\Exceptions\ClientNotFoundException; use Application\Security\Models\Entities\Client; use Application\Security\Models\ValueObjects\ClientIdentifier; -use Application\Security\Ports\Repositories\ClientRepository; -use Application\Security\Usecases\FindClient\FindClient; -use Application\Security\Usecases\FindClient\Input as FindClientInput; use Application\Security\Usecases\RegenerateClientSecret\Input; use Application\Security\Usecases\RegenerateClientSecret\RegenerateClientSecret; use Illuminate\Console\Command; -use Presentation\Console\Presenters\Clients\FindClientPresenter; -use Presentation\Console\Presenters\Clients\FindClientView; -use Presentation\Console\Presenters\Clients\FoundClientItem; use Presentation\Console\Presenters\Clients\RegenerateClientSecretPresenter; use Presentation\Console\Presenters\Clients\RegenerateClientSecretView; +use Presentation\Console\Services\ClientConsoleResolver; use Shared\DDD\Usecase\Builder\UsecaseBuilder; use Shared\DDD\Usecase\Builder\UsecaseExecutor; -use Shared\Pagination; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; -use Symfony\Component\Console\Completion\Suggestion; final class RegenerateClientSecretCommand extends Command { @@ -39,7 +32,7 @@ final class RegenerateClientSecretCommand extends Command protected $description = 'Regenerate the secret of an API client'; public function __construct( - private readonly ClientRepository $clientRepository, + private readonly ClientConsoleResolver $clientResolver, ) { parent::__construct(); } @@ -50,41 +43,41 @@ final class RegenerateClientSecretCommand extends Command parent::complete($input, $suggestions); if ($input->mustSuggestArgumentValuesFor('identifier')) { - $clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100)); - - /** @var list $items */ - $items = array_map( - static fn (Client $client): Suggestion => new Suggestion( - value: $client->identifier->value, - description: sprintf('%s (%s)', $client->name->value, $client->isActive ? 'Active' : 'Inactive'), - ), - $clients->getItems(), - ); - $suggestions->suggestValues($items); + $this->clientResolver->complete($suggestions); } } public function handle( RegenerateClientSecret $usecase, - FindClient $findClient, UsecaseExecutor $executor, ): int { - $identifierString = $this->resolveIdentifier($findClient, $executor); + $raw = $this->argument('identifier'); + $rawString = is_string($raw) ? $raw : $this->anticipate( + 'Search client (by name, ID, identifier, or slug):', + fn (string $input): array => $this->clientResolver->searchSuggestions($input), + ); + + if (! is_string($rawString) || trim($rawString) === '') { + $this->error('No client specified.'); + + return self::FAILURE; + } + + $inputString = trim($rawString); + $client = $this->clientResolver->find($inputString); + + if ($client === null) { + try { + new ClientIdentifier($inputString); + $this->error("Client '{$inputString}' not found."); + } catch (\InvalidArgumentException) { + $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); + } - if ($identifierString === null) { return self::FAILURE; } try { - $clientIdentifier = $this->parseClientIdentifier($identifierString, $findClient, $executor); - $client = $this->clientRepository->findByIdentifier($clientIdentifier); - - if ($client === null) { - $this->error(sprintf("Client '%s' not found.", $clientIdentifier->value)); - - return self::FAILURE; - } - if (! $this->confirmRegeneration($client)) { $this->warn('Secret regeneration cancelled.'); @@ -110,90 +103,6 @@ final class RegenerateClientSecretCommand extends Command } } - private function resolveIdentifier(FindClient $findClient, UsecaseExecutor $executor): ?string - { - $raw = $this->argument('identifier'); - - if ($raw !== null) { - return (string) $raw; - } - - /** @var string|null $answer */ - $answer = $this->anticipate( - 'Search client (by name, ID, identifier, or slug):', - function (string $input) use ($findClient, $executor): array { - $trimmed = trim($input); - - if ($trimmed === '') { - return []; - } - - try { - /** @var FindClientView $view */ - $view = UsecaseBuilder::using($executor) - ->do($findClient) - ->from(new FindClientInput($trimmed)) - ->with(new FindClientPresenter()) - ->run(); - - return array_map( - static fn (FoundClientItem $item): string => $item->identifier, - $view->clients, - ); - } catch (\Throwable) { - return []; - } - } - ); - - if ($answer === null || trim($answer) === '') { - $this->error('No client specified.'); - - return null; - } - - return trim($answer); - } - - /** - * @throws \InvalidArgumentException - */ - private function parseClientIdentifier( - string $value, - FindClient $findClient, - UsecaseExecutor $executor, - ): ClientIdentifier { - try { - return new ClientIdentifier($value); - } catch (\InvalidArgumentException) { - try { - /** @var FindClientView $view */ - $view = UsecaseBuilder::using($executor) - ->do($findClient) - ->from(new FindClientInput($value)) - ->with(new FindClientPresenter()) - ->run(); - - if (count($view->clients) === 1) { - return new ClientIdentifier($view->clients[0]->identifier); - } - - if (count($view->clients) > 1) { - foreach ($view->clients as $found) { - if ( - strcasecmp($found->name, $value) === 0 || strcasecmp($found->id, $value) === 0 - ) { - return new ClientIdentifier($found->identifier); - } - } - } - } catch (\Throwable) { - } - - throw new \InvalidArgumentException("Invalid client identifier '{$value}'. Expected a valid client identifier."); - } - } - private function confirmRegeneration(Client $client): bool { $this->newLine(); diff --git a/src/Presentation/Console/Commands/ToggleClientStatusCommand.php b/src/Presentation/Console/Commands/ToggleClientStatusCommand.php index 4a30553..083f622 100644 --- a/src/Presentation/Console/Commands/ToggleClientStatusCommand.php +++ b/src/Presentation/Console/Commands/ToggleClientStatusCommand.php @@ -7,19 +7,13 @@ namespace Presentation\Console\Commands; use Application\Security\Exceptions\ClientNotFoundException; use Application\Security\Models\Entities\Client; use Application\Security\Models\ValueObjects\ClientIdentifier; -use Application\Security\Ports\Repositories\ClientRepository; -use Application\Security\Usecases\FindClient\FindClient; -use Application\Security\Usecases\FindClient\Input as FindClientInput; use Application\Security\Usecases\ToggleClientStatus\Input; use Application\Security\Usecases\ToggleClientStatus\SetClientStatus; use Illuminate\Console\Command; -use Presentation\Console\Presenters\Clients\FindClientPresenter; -use Presentation\Console\Presenters\Clients\FindClientView; -use Presentation\Console\Presenters\Clients\FoundClientItem; use Presentation\Console\Presenters\Clients\SetClientStatusPresenter; +use Presentation\Console\Services\ClientConsoleResolver; use Shared\DDD\Usecase\Builder\UsecaseBuilder; use Shared\DDD\Usecase\Builder\UsecaseExecutor; -use Shared\Pagination; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Suggestion; @@ -39,7 +33,7 @@ final class ToggleClientStatusCommand extends Command protected $description = 'Toggle the status of an API client'; public function __construct( - private readonly ClientRepository $clientRepository, + private readonly ClientConsoleResolver $clientResolver, ) { parent::__construct(); } @@ -50,17 +44,7 @@ final class ToggleClientStatusCommand extends Command parent::complete($input, $suggestions); if ($input->mustSuggestArgumentValuesFor('identifier')) { - $clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100)); - - /** @var list $items */ - $items = array_map( - static fn (Client $client): Suggestion => new Suggestion( - value: $client->identifier->value, - description: sprintf('%s (%s)', $client->name->value, $client->isActive ? 'Active' : 'Inactive'), - ), - $clients->getItems(), - ); - $suggestions->suggestValues($items); + $this->clientResolver->complete($suggestions); } if ($input->mustSuggestArgumentValuesFor('status')) { @@ -73,20 +57,39 @@ final class ToggleClientStatusCommand extends Command public function handle( SetClientStatus $usecase, - FindClient $findClient, UsecaseExecutor $executor, ): int { - $identifierString = $this->resolveIdentifier($findClient, $executor); + $raw = $this->argument('identifier'); + $rawString = is_string($raw) ? $raw : $this->anticipate( + 'Search client (by name, ID, identifier, or slug):', + fn (string $input): array => $this->clientResolver->searchSuggestions($input), + ); + + if (! is_string($rawString) || trim($rawString) === '') { + $this->error('No client specified.'); + + return self::FAILURE; + } + + $inputString = trim($rawString); + $client = $this->clientResolver->find($inputString); + + if ($client === null) { + try { + new ClientIdentifier($inputString); + $this->error("Client '{$inputString}' not found."); + } catch (\InvalidArgumentException) { + $this->error("Invalid client identifier '{$inputString}'. Expected a valid client identifier."); + } - if ($identifierString === null) { return self::FAILURE; } try { - $clientIdentifier = $this->parseClientIdentifier($identifierString, $findClient, $executor); - $desiredStatus = $this->resolveDesiredStatus($clientIdentifier); - $input = new Input($clientIdentifier, $desiredStatus); + $desiredStatus = $this->resolveDesiredStatus($client); + $input = new Input($client->identifier, $desiredStatus); + /** @var \Presentation\Console\Presenters\Clients\ToggleClientStatusView $view */ $view = UsecaseBuilder::using($executor) ->do($usecase) ->from($input) @@ -103,107 +106,15 @@ final class ToggleClientStatusCommand extends Command } } - private function resolveIdentifier(FindClient $findClient, UsecaseExecutor $executor): ?string - { - $raw = $this->argument('identifier'); - - if ($raw !== null) { - return (string) $raw; - } - - /** @var string|null $answer */ - $answer = $this->anticipate( - 'Search client (by name, ID, identifier, or slug):', - function (string $input) use ($findClient, $executor): array { - $trimmed = trim($input); - - if ($trimmed === '') { - return []; - } - - try { - /** @var FindClientView $view */ - $view = UsecaseBuilder::using($executor) - ->do($findClient) - ->from(new FindClientInput($trimmed)) - ->with(new FindClientPresenter()) - ->run(); - - return array_map( - static fn (FoundClientItem $item): string => $item->identifier, - $view->clients, - ); - } catch (\Throwable) { - return []; - } - } - ); - - if ($answer === null || trim($answer) === '') { - $this->error('No client specified.'); - - return null; - } - - return trim($answer); - } - - /** - * @throws \InvalidArgumentException - */ - private function parseClientIdentifier( - string $value, - FindClient $findClient, - UsecaseExecutor $executor, - ): ClientIdentifier { - try { - return new ClientIdentifier($value); - } catch (\InvalidArgumentException) { - try { - /** @var FindClientView $view */ - $view = UsecaseBuilder::using($executor) - ->do($findClient) - ->from(new FindClientInput($value)) - ->with(new FindClientPresenter()) - ->run(); - - if (count($view->clients) === 1) { - return new ClientIdentifier($view->clients[0]->identifier); - } - - if (count($view->clients) > 1) { - foreach ($view->clients as $found) { - if ( - strcasecmp($found->name, $value) === 0 || strcasecmp($found->id, $value) === 0 - ) { - return new ClientIdentifier($found->identifier); - } - } - } - } catch (\Throwable) { - } - - throw new \InvalidArgumentException("Invalid client identifier '{$value}'. Expected a valid client identifier."); - } - } - - private function resolveDesiredStatus(ClientIdentifier $clientIdentifier): bool + private function resolveDesiredStatus(Client $client): bool { $status = $this->argument('status') !== null ? (string) $this->argument('status') : null; return match ($status) { 'activate' => true, 'deactivate' => false, - null => $this->resolveToggledStatus($clientIdentifier), + null => ! $client->isActive, default => throw new \InvalidArgumentException("Invalid status '{$status}'. Expected 'activate' or 'deactivate'."), }; } - - private function resolveToggledStatus(ClientIdentifier $clientIdentifier): bool - { - $client = $this->clientRepository->findByIdentifier($clientIdentifier) - ?? throw ClientNotFoundException::forClient($clientIdentifier->value); - - return ! $client->isActive; - } } diff --git a/src/Presentation/Console/Services/ClientConsoleResolver.php b/src/Presentation/Console/Services/ClientConsoleResolver.php new file mode 100644 index 0000000..c651533 --- /dev/null +++ b/src/Presentation/Console/Services/ClientConsoleResolver.php @@ -0,0 +1,135 @@ +clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100)); + + /** @var list $items */ + $items = array_map( + static fn (Client $client): Suggestion => new Suggestion( + value: $client->identifier->value, + description: sprintf('%s (%s)', $client->name->value, $client->isActive ? 'Active' : 'Inactive'), + ), + $clients->getItems(), + ); + + $suggestions->suggestValues($items); + } + + /** + * Provide suggestions for console anticipate() prompt. + * + * @return list + */ + public function searchSuggestions(string $input): array + { + $trimmed = trim($input); + + if ($trimmed === '') { + return []; + } + + try { + /** @var \Presentation\Console\Presenters\Clients\FindClientView $view */ + $view = UsecaseBuilder::using($this->executor) + ->do($this->findClient) + ->from(new FindClientInput($trimmed)) + ->with(new FindClientPresenter()) + ->run(); + + return array_map( + static fn (FoundClientItem $item): string => $item->identifier, + $view->clients, + ); + } catch (\Throwable) { + return []; + } + } + + /** + * Find a Client by identifier, or by searching matching name, slug, or ID. + */ + public function find(string $input): ?Client + { + $trimmed = trim($input); + + if ($trimmed === '') { + return null; + } + + // 1. Direct lookup by exact ClientIdentifier format + try { + $identifier = new ClientIdentifier($trimmed); + $client = $this->clientRepository->findByIdentifier($identifier); + + if ($client !== null) { + return $client; + } + } catch (\InvalidArgumentException) { + // Not a valid ClientIdentifier format, fallback to search via FindClient + $isExactIdentifier = false; + } + + // 2. Search via FindClient use case + try { + /** @var \Presentation\Console\Presenters\Clients\FindClientView $view */ + $view = UsecaseBuilder::using($this->executor) + ->do($this->findClient) + ->from(new FindClientInput($trimmed)) + ->with(new FindClientPresenter()) + ->run(); + + if (count($view->clients) === 1) { + $identifier = new ClientIdentifier($view->clients[0]->identifier); + + return $this->clientRepository->findByIdentifier($identifier); + } + + if (count($view->clients) > 1) { + foreach ($view->clients as $found) { + if ( + strcasecmp($found->name, $trimmed) === 0 || strcasecmp($found->id, $trimmed) === 0 + ) { + $identifier = new ClientIdentifier($found->identifier); + + return $this->clientRepository->findByIdentifier($identifier); + } + } + } + } catch (\Throwable) { + // Search error fallback + $searchFailed = true; + } + + return null; + } +}