refactor(console): encapsulate interactive search and autocompletion in ClientConsoleResolver
This commit is contained in:
parent
2d46f774b3
commit
90c87899a2
3 changed files with 190 additions and 235 deletions
|
|
@ -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<Suggestion> $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();
|
||||
|
|
|
|||
|
|
@ -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<Suggestion> $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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
src/Presentation/Console/Services/ClientConsoleResolver.php
Normal file
135
src/Presentation/Console/Services/ClientConsoleResolver.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Services;
|
||||
|
||||
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 Presentation\Console\Presenters\Clients\FindClientPresenter;
|
||||
use Presentation\Console\Presenters\Clients\FoundClientItem;
|
||||
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
|
||||
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
|
||||
use Shared\Pagination;
|
||||
use Symfony\Component\Console\Completion\CompletionSuggestions;
|
||||
use Symfony\Component\Console\Completion\Suggestion;
|
||||
|
||||
final readonly class ClientConsoleResolver
|
||||
{
|
||||
public function __construct(
|
||||
private ClientRepository $clientRepository,
|
||||
private FindClient $findClient,
|
||||
private UsecaseExecutor $executor,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide shell autocompletion suggestions for client identifiers.
|
||||
*/
|
||||
public function complete(CompletionSuggestions $suggestions): void
|
||||
{
|
||||
$clients = $this->clientRepository->findAll(Pagination::ofOffsetAndLimit(0, 100));
|
||||
|
||||
/** @var list<Suggestion> $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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue