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/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/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) {