Migrate GenerateKeyCommand to symfony/console attributes

This commit is contained in:
Alejandro Celaya
2025-12-13 16:49:52 +01:00
parent c53f538c79
commit 5df3abbce9
6 changed files with 92 additions and 127 deletions

View File

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

View File

@@ -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<RoleDefinition>
*/
public function determineRoles(InputInterface $input): iterable;
public function determineRoles(ApiKeyInput $input): iterable;
}

View File

@@ -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 = <<<HELP
#[AsCommand(
name: GenerateKeyCommand::NAME,
description: 'Generate a new valid API key',
help: <<<HELP
The <info>%command.name%</info> generates a new valid API key.
<info>%command.full_name%</info>
@@ -49,62 +36,26 @@ class GenerateKeyCommand extends Command
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
<info>%command.full_name% --expiration-date 2020-01-01</info>
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: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Cannot see orphan visits: <info>%command.full_name% --{$noOrphanVisits}</info>
* All: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}</info>
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);

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api\Input;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Attribute\Option;
final class ApiKeyInput
{
#[Option('The unique name by which this API key will be known', shortcut: 'm')]
public string|null $name = null;
#[Option('The date in which the API key should expire. Use any valid PHP format', shortcut: 'e')]
public string|null $expirationDate = null;
/** @deprecated */
#[Option('Adds the "' . Role::AUTHORED_SHORT_URLS->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;
}

View File

@@ -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);

View File

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