diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index ece56c77..00c66e7c 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -4,15 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\ApiKey; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Role; -use Symfony\Component\Console\Input\InputInterface; - -use function is_string; +/** @deprecated API key roles are deprecated */ readonly class RoleResolver implements RoleResolverInterface { public function __construct( @@ -21,16 +19,16 @@ readonly class RoleResolver implements RoleResolverInterface ) { } - public function determineRoles(InputInterface $input): iterable + public function determineRoles(ApiKeyInput $input): iterable { - $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); - $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); - $noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName()); + $domainAuthority = $input->domain; + $author = $input->authorOnly; + $noOrphanVisits = $input->noOrphanVisits; if ($author) { yield RoleDefinition::forAuthoredShortUrls(); } - if (is_string($domainAuthority)) { + if ($domainAuthority !== null) { yield $this->resolveRoleForAuthority($domainAuthority); } if ($noOrphanVisits) { diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php index e849ad13..6f90843b 100644 --- a/module/CLI/src/ApiKey/RoleResolverInterface.php +++ b/module/CLI/src/ApiKey/RoleResolverInterface.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\ApiKey; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Symfony\Component\Console\Input\InputInterface; +/** @deprecated API key roles are deprecated */ interface RoleResolverInterface { /** * @return iterable */ - public function determineRoles(InputInterface $input): iterable; + public function determineRoles(ApiKeyInput $input): iterable; } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 0c4afcd0..b9535b94 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -4,40 +4,27 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use Cake\Chronos\Chronos; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function Shlinkio\Shlink\Core\arrayToString; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function sprintf; -class GenerateKeyCommand extends Command -{ - public const string NAME = 'api-key:generate'; - - public function __construct( - private readonly ApiKeyServiceInterface $apiKeyService, - private readonly RoleResolverInterface $roleResolver, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); - $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); - $noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName(); - - $help = <<%command.name% generates a new valid API key. %command.full_name% @@ -49,62 +36,26 @@ class GenerateKeyCommand extends Command You can optionally set its expiration date with --expiration-date or -e: %command.full_name% --expiration-date 2020-01-01 + HELP, +)] +class GenerateKeyCommand extends Command +{ + public const string NAME = 'api-key:generate'; - You can also set roles to the API key: - - * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} - * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com - * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits} - * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits} - HELP; - - $this - ->setName(self::NAME) - ->setDescription('Generate a new valid API key.') - ->addOption( - 'name', - 'm', - InputOption::VALUE_REQUIRED, - 'The name by which this API key will be known.', - ) - ->addOption( - 'expiration-date', - 'e', - InputOption::VALUE_REQUIRED, - 'The date in which the API key should expire. Use any valid PHP format.', - ) - ->addOption( - $authorOnly, - 'a', - InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value), - ) - ->addOption( - $domainOnly, - 'd', - InputOption::VALUE_REQUIRED, - sprintf( - 'Adds the "%s" role to the new API key, with the domain provided.', - Role::DOMAIN_SPECIFIC->value, - ), - ) - ->addOption( - $noOrphanVisits, - 'o', - InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value), - ) - ->setHelp($help); + public function __construct( + private readonly ApiKeyServiceInterface $apiKeyService, + private readonly RoleResolverInterface $roleResolver, + ) { + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output): int + public function __invoke(SymfonyStyle $io, InputInterface $input, #[MapInput] ApiKeyInput $inputData): int { - $io = new SymfonyStyle($input, $output); - $expirationDate = $input->getOption('expiration-date'); + $expirationDate = $inputData->expirationDate; $apiKeyMeta = ApiKeyMeta::fromParams( - name: $input->getOption('name'), - expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null, - roleDefinitions: $this->roleResolver->determineRoles($input), + name: $inputData->name, + expirationDate: isset($expirationDate) ? normalizeOptionalDate($expirationDate) : null, + roleDefinitions: $this->roleResolver->determineRoles($inputData), ); $apiKey = $this->apiKeyService->create($apiKeyMeta); diff --git a/module/CLI/src/Command/Api/Input/ApiKeyInput.php b/module/CLI/src/Command/Api/Input/ApiKeyInput.php new file mode 100644 index 00000000..ae19efc7 --- /dev/null +++ b/module/CLI/src/Command/Api/Input/ApiKeyInput.php @@ -0,0 +1,33 @@ +value . '" role to the new API key', shortcut: 'a')] + public bool $authorOnly = false; + + /** @deprecated */ + #[Option( + 'Adds the "' . Role::DOMAIN_SPECIFIC->value . '" role to the new API key, with provided domain', + name: 'domain-only', + shortcut: 'd', + )] + public string|null $domain = null; + + /** @deprecated */ + #[Option('Adds the "' . Role::NO_ORPHAN_VISITS->value . '" role to the new API key', shortcut: 'o')] + public bool $noOrphanVisits = false; +} diff --git a/module/CLI/src/Command/Api/RenameApiKeyCommand.php b/module/CLI/src/Command/Api/RenameApiKeyCommand.php index fcbca1ce..fc0ec9bb 100644 --- a/module/CLI/src/Command/Api/RenameApiKeyCommand.php +++ b/module/CLI/src/Command/Api/RenameApiKeyCommand.php @@ -4,19 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Model\Renaming; -use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Ask; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function Shlinkio\Shlink\Core\ArrayUtils\map; - #[AsCommand( name: RenameApiKeyCommand::NAME, description: 'Renames an API key by name', @@ -30,38 +25,12 @@ class RenameApiKeyCommand extends Command parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - $oldName = $input->getArgument('old-name'); - $newName = $input->getArgument('new-name'); - - if ($oldName === null) { - $apiKeys = $this->apiKeyService->listKeys(); - $requestedOldName = $io->choice( - 'What API key do you want to rename?', - map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), - ); - - $input->setArgument('old-name', $requestedOldName); - } - - if ($newName === null) { - $requestedNewName = $io->ask( - 'What is the new name you want to set?', - validator: static fn (string|null $value): string => $value !== null - ? $value - : throw new InvalidArgumentException('The new name cannot be empty'), - ); - - $input->setArgument('new-name', $requestedNewName); - } - } - public function __invoke( SymfonyStyle $io, - #[Argument(description: 'Current name of the API key to rename')] string $oldName, - #[Argument(description: 'New name to set to the API key')] string $newName, + #[Argument(description: 'Current name of the API key to rename'), Ask('What API key do you want to rename?')] + string $oldName, + #[Argument(description: 'New name to set to the API key'), Ask('What is the new name you want to set?')] + string $newName, ): int { $this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName)); $io->success('API key properly renamed'); diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index e3a38be6..527c0d12 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -8,10 +8,10 @@ use Closure; use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Interact; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function Shlinkio\Shlink\Config\formatEnvVarValue; @@ -37,9 +37,10 @@ class ReadEnvVarCommand extends Command parent::__construct(); } - protected function interact(InputInterface $input, OutputInterface $output): void + #[Interact] + public function askMissing(InputInterface $input, SymfonyStyle $io): void { - $io = new SymfonyStyle($input, $output); + /** @var string|null $envVar */ $envVar = $input->getArgument('env-var'); $validEnvVars = enumValues(EnvVars::class); diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index ac47a262..28d194c3 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -9,12 +9,12 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\ApiKey\RoleResolver; +use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Role; use Symfony\Component\Console\Input\InputInterface; class RoleResolverTest extends TestCase @@ -30,11 +30,10 @@ class RoleResolverTest extends TestCase #[Test, DataProvider('provideRoles')] public function properRolesAreResolvedBasedOnInput( - callable $createInput, + ApiKeyInput $input, array $expectedRoles, int $expectedDomainCalls, ): void { - $input = $createInput($this); $this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with( 'example.com', )->willReturn(self::domainWithId(Domain::withAuthority('example.com'))); @@ -60,43 +59,39 @@ class RoleResolverTest extends TestCase }; yield 'no roles' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]), + new ApiKeyInput(), [], 0, ]; yield 'domain role only' => [ - $buildInput( - [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false], - ), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->domain = 'example.com'; + + return $input; + })(), [RoleDefinition::forDomain($domain)], 1, ]; - yield 'false domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]), - [], - 0, - ]; - yield 'true domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]), - [], - 0, - ]; - yield 'string array domain role' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]), - [], - 0, - ]; yield 'author role only' => [ - $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->authorOnly = true; + + return $input; + })(), [RoleDefinition::forAuthoredShortUrls()], 0, ]; yield 'all roles' => [ - $buildInput([ - Role::DOMAIN_SPECIFIC->paramName() => 'example.com', - Role::AUTHORED_SHORT_URLS->paramName() => true, - Role::NO_ORPHAN_VISITS->paramName() => true, - ]), + (function (): ApiKeyInput { + $input = new ApiKeyInput(); + $input->domain = 'example.com'; + $input->authorOnly = true; + $input->noOrphanVisits = true; + + return $input; + })(), [ RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain), @@ -109,13 +104,8 @@ class RoleResolverTest extends TestCase #[Test] public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void { - $input = $this->createStub(InputInterface::class); - $input - ->method('getOption') - ->willReturnMap([ - [Role::DOMAIN_SPECIFIC->paramName(), 'default.com'], - [Role::AUTHORED_SHORT_URLS->paramName(), null], - ]); + $input = new ApiKeyInput(); + $input->domain = 'default.com'; $this->expectException(InvalidRoleConfigException::class); diff --git a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php index d8c5f07f..2fe4fb9d 100644 --- a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php @@ -9,8 +9,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Api\RenameApiKeyCommand; use Shlinkio\Shlink\Core\Model\Renaming; -use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; -use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; @@ -32,11 +30,6 @@ class RenameApiKeyCommandTest extends TestCase $oldName = 'old name'; $newName = 'new name'; - $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([ - ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')), - ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)), - ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')), - ]); $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( Renaming::fromNames($oldName, $newName), ); @@ -53,7 +46,6 @@ class RenameApiKeyCommandTest extends TestCase $oldName = 'old name'; $newName = 'new name'; - $this->apiKeyService->expects($this->never())->method('listKeys'); $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( Renaming::fromNames($oldName, $newName), ); @@ -70,7 +62,6 @@ class RenameApiKeyCommandTest extends TestCase $oldName = 'old name'; $newName = 'new name'; - $this->apiKeyService->expects($this->never())->method('listKeys'); $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( Renaming::fromNames($oldName, $newName), ); diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 7cca292d..7668e285 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; use function sprintf; +/** @deprecated API key roles are deprecated */ enum Role: string { case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; @@ -29,15 +30,6 @@ enum Role: string }; } - public function paramName(): string - { - return match ($this) { - self::AUTHORED_SHORT_URLS => 'author-only', - self::DOMAIN_SPECIFIC => 'domain-only', - self::NO_ORPHAN_VISITS => 'no-orphan-visits', - }; - } - public static function toSpec(ApiKeyRole $role, string|null $context = null): Specification { return match ($role->role) {