Map models in CLI via TreeMapper

This commit is contained in:
Alejandro Celaya
2026-05-10 09:04:30 +02:00
parent c162bb6145
commit a4f39c18eb
14 changed files with 108 additions and 197 deletions
+8 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use CuyZ\Valinor\Mapper\TreeMapper;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
@@ -87,12 +88,18 @@ return [
ShortUrl\UrlShortener::class,
ShortUrlStringifier::class,
UrlShortenerOptions::class,
TreeMapper::class,
],
Command\ShortUrl\EditShortUrlCommand::class => [
ShortUrl\ShortUrlService::class,
ShortUrlStringifier::class,
TreeMapper::class,
],
Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class],
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
ShortUrl\ShortUrlListService::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
TreeMapper::class,
],
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use CuyZ\Valinor\Mapper\TreeMapper;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlCreationInput;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
@@ -28,6 +30,7 @@ class CreateShortUrlCommand extends Command
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options,
private readonly TreeMapper $treeMapper,
) {
parent::__construct();
}
@@ -35,7 +38,9 @@ class CreateShortUrlCommand extends Command
public function __invoke(SymfonyStyle $io, #[MapInput] ShortUrlCreationInput $inputData): int
{
try {
$result = $this->urlShortener->shorten($inputData->toShortUrlCreation($this->options));
$result = $this->urlShortener->shorten(
$this->treeMapper->map(ShortUrlCreation::class, $inputData->toArray($this->options)),
);
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use CuyZ\Valinor\Mapper\TreeMapper;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlDataInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
@@ -29,6 +31,7 @@ class EditShortUrlCommand extends Command
public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly TreeMapper $treeMapper,
) {
parent::__construct();
}
@@ -45,7 +48,7 @@ class EditShortUrlCommand extends Command
try {
$shortUrl = $this->shortUrlService->updateShortUrl(
$identifier,
$data->toShortUrlEdition($longUrl),
$this->treeMapper->map(ShortUrlEdition::class, $data->toArray($longUrl)),
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
@@ -5,14 +5,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use function array_filter;
use function get_object_vars;
use function max;
use const ARRAY_FILTER_USE_KEY;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
/**
* Data used for short URL creation
*/
@@ -42,18 +46,22 @@ final class ShortUrlCreationInput
)]
public bool $findIfExists = false;
public function toShortUrlCreation(UrlShortenerOptions $options): ShortUrlCreation
public function toArray(UrlShortenerOptions $options): array
{
// TODO Should create using a TreeMapper
$shortCodeLength = max(4, $this->shortCodeLength ?? $options->defaultShortCodesLength);
return new ShortUrlCreation(
$this->longUrl,
...$this->commonData->toArray(),
customSlug: $this->customSlug,
pathPrefix: $this->pathPrefix,
findIfExists: $this->findIfExists,
domain: $this->domain,
shortCodeLength: $shortCodeLength,
$common = $this->commonData->toArray($this->longUrl);
$creation = array_filter(
get_object_vars($this),
static fn (string $key) => $key !== 'commonData',
ARRAY_FILTER_USE_KEY,
);
$shortCodeLength = max(MIN_SHORT_CODES_LENGTH, $this->shortCodeLength ?? $options->defaultShortCodesLength);
return [
...$common,
...$creation,
'shortCodeLength' => $shortCodeLength,
'shortUrlMode' => $options->mode,
'multiSegmentSlugsEnabled' => $options->multiSegmentSlugsEnabled,
];
}
}
@@ -4,10 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Symfony\Component\Console\Attribute\Option;
use function array_filter;
use function array_unique;
use function get_object_vars;
/**
* Common input used for short URL creation and edition
@@ -16,7 +17,11 @@ final class ShortUrlDataInput
{
/** @var string[]|null */
#[Option('Tags to apply to the short URL', name: 'tag', shortcut: 't')]
public array|null $tags = null;
// phpcs:disable PSR2.Classes.PropertyDeclaration.Multiple
public array|null $tags = null {
// phpcs:disable PSR2.Classes.PropertyDeclaration.Multiple, PSR2.Classes.PropertyDeclaration.ScopeMissing
set(array | null $value) => $value !== null ? array_unique($value) : $value;
}
#[Option(
'The date from which this short URL will be valid. '
@@ -47,56 +52,19 @@ final class ShortUrlDataInput
)]
public bool|null $noForwardQuery = null;
public function toArray(): array
public function toArray(string|null $longUrl): array
{
$data = [];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if ($this->validSince !== null) {
$data['validSince'] = $this->validSince;
}
if ($this->validUntil !== null) {
$data['validUntil'] = $this->validUntil;
}
if ($this->maxVisits !== null) {
$data['maxVisits'] = $this->maxVisits;
}
if ($this->tags !== null) {
$data['tags'] = array_unique($this->tags);
}
if ($this->title !== null) {
$data['title'] = $this->title;
}
if ($this->crawlable !== null) {
$data['crawlable'] = $this->crawlable;
}
if ($this->noForwardQuery !== null) {
$data['forwardQuery'] = !$this->noForwardQuery;
}
return $data;
}
public function toShortUrlEdition(string|null $longUrl): ShortUrlEdition
{
return new ShortUrlEdition(
longUrlWasProvided: $longUrl !== null,
longUrl: $longUrl,
validSinceWasProvided: $this->validSince !== null,
validSince: $this->validSince,
validUntilWasProvided: $this->validUntil !== null,
validUntil: $this->validUntil,
maxVisitsWasProvided: $this->maxVisits !== null,
maxVisits: $this->maxVisits,
tagsWereProvided: $this->tags !== null,
tags: $this->tags ?? [],
titleWasProvided: $this->title !== null,
title: $this->title,
crawlableWasProvided: $this->crawlable !== null,
crawlable: $this->crawlable ?? false,
forwardQueryWasProvided: $this->noForwardQuery !== null,
forwardQuery: $this->noForwardQuery !== null && ! $this->noForwardQuery,
);
return [
...array_filter(get_object_vars($this), static fn (mixed $value) => $value !== null),
'longUrl' => $longUrl,
'longUrlWasProvided' => $longUrl !== null,
'validSinceWasProvided' => $this->validSince !== null,
'validUntilWasProvided' => $this->validUntil !== null,
'maxVisitsWasProvided' => $this->maxVisits !== null,
'tagsWereProvided' => $this->tags !== null,
'titleWasProvided' => $this->title !== null,
'crawlableWasProvided' => $this->crawlable !== null,
'forwardQueryWasProvided' => $this->noForwardQuery !== null,
];
}
}
@@ -5,14 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Output\OutputInterface;
use function get_object_vars;
/**
* Input arguments and options for short-url:list command
@@ -29,6 +28,12 @@ final class ShortUrlsParamsInput
)]
public bool $all = false;
// phpcs:disable PSR2.Classes.PropertyDeclaration.Multiple
public int $itemsPerPage {
// phpcs:disable PSR2.Classes.PropertyDeclaration.ScopeMissing
get => $this->all ? Paginator::ALL_ITEMS : ShortUrlsParams::DEFAULT_ITEMS_PER_PAGE;
}
#[Option('Only return short URLs older than this date', shortcut: 's')]
public string|null $startDate = null;
@@ -44,20 +49,32 @@ final class ShortUrlsParamsInput
)]
public string|null $domain = null;
/** @var string[]|null */
/** @var string[] */
#[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')]
public array|null $tags = null;
public array $tags = [];
#[Option('If --tag is provided, returns only short URLs including ALL of them')]
public bool $tagsAll = false;
/** @var string[]|null */
// phpcs:disable PSR2.Classes.PropertyDeclaration.Multiple
public TagsMode $tagsMode {
// phpcs:disable PSR2.Classes.PropertyDeclaration.ScopeMissing
get => $this->tagsAll ? TagsMode::ALL : TagsMode::ANY;
}
/** @var string[] */
#[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')]
public array|null $excludeTags = null;
public array $excludeTags = [];
#[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')]
public bool $excludeTagsAll = false;
// phpcs:disable PSR2.Classes.PropertyDeclaration.Multiple
public TagsMode $excludeTagsMode {
// phpcs:disable PSR2.Classes.PropertyDeclaration.ScopeMissing
get => $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY;
}
#[Option('Excludes short URLs which reached their max amount of visits')]
public bool $excludeMaxVisitsReached = false;
@@ -85,23 +102,8 @@ final class ShortUrlsParamsInput
#[Option('Whether to display the API key name from which the URL was generated or not', shortcut: 'k')]
public bool $showApiKey = false;
public function toParams(OutputInterface $output): ShortUrlsParams
public function toArray(): array
{
return new ShortUrlsParams(
page: $this->page,
itemsPerPage: $this->all ? Paginator::ALL_ITEMS : ShortUrlsParams::DEFAULT_ITEMS_PER_PAGE,
searchTerm: $this->searchTerm,
tags: $this->tags ?? [],
orderBy: Ordering::fromOptionalString($this->orderBy),
startDate: InputUtils::processDate('start-date', $this->startDate, $output),
endDate: InputUtils::processDate('end-date', $this->endDate, $output),
excludeMaxVisitsReached: $this->excludeMaxVisitsReached,
excludePastValidUntil: $this->excludePastValidUntil,
tagsMode: $this->tagsAll ? TagsMode::ALL : TagsMode::ANY,
domain: $this->domain,
excludeTags: $this->excludeTags ?? [],
excludeTagsMode: $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY,
apiKeyName: $this->apiKeyName,
);
return get_object_vars($this);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use CuyZ\Valinor\Mapper\TreeMapper;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlsParamsInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
@@ -17,7 +18,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
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\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -34,18 +34,19 @@ class ListShortUrlsCommand extends Command
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly ShortUrlDataTransformerInterface $transformer,
private readonly TreeMapper $treeMapper,
) {
parent::__construct();
}
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[MapInput] ShortUrlsParamsInput $paramsInput,
): int {
$columnsMap = $this->resolveColumnsMap($input);
$columnsMap = $this->resolveColumnsMap($paramsInput);
do {
$result = $this->renderPage($io, $columnsMap, $paramsInput->toParams($io), $paramsInput->all);
$params = $this->treeMapper->map(ShortUrlsParams::class, $paramsInput->toArray());
$result = $this->renderPage($io, $columnsMap, $params, $paramsInput->all);
$paramsInput->page += 1;
$continue = $result->hasNextPage() && $io->confirm(
@@ -89,7 +90,7 @@ class ListShortUrlsCommand extends Command
/**
* @return array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string>
*/
private function resolveColumnsMap(InputInterface $input): array
private function resolveColumnsMap(ShortUrlsParamsInput $params): array
{
$pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop];
$columnsMap = [
@@ -100,14 +101,14 @@ class ListShortUrlsCommand extends Command
'Date created' => $pickProp('dateCreated'),
'Visits count' => static fn (array $shortUrl) => $shortUrl['visitsSummary']->total,
];
if ($input->getOption('show-tags')) {
if ($params->showTags) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
}
if ($input->getOption('show-domain')) {
if ($params->showDomain) {
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key')) {
if ($params->showApiKey) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function Shlinkio\Shlink\Common\normalizeOptionalDate;
use function sprintf;
final class InputUtils
{
/**
* Process a date provided via input params, and format it as ATOM.
* A warning is printed if the date cannot be parsed, returning `null` in that case.
*/
public static function processDate(string $name, string|null $value, OutputInterface $output): string|null
{
if ($value === null || $value === '') {
return null;
}
try {
return normalizeOptionalDate($value)->toAtomString();
} catch (Throwable) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$name,
$value,
));
return null;
}
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use CuyZ\Valinor\MapperBuilder;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -39,6 +40,7 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener,
$this->stringifier,
new UrlShortenerOptions(defaultDomain: 'example.com', defaultShortCodesLength: 5),
new MapperBuilder()->allowSuperfluousKeys()->mapper(),
);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
@@ -2,6 +2,7 @@
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use CuyZ\Valinor\MapperBuilder;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
@@ -29,7 +30,7 @@ class EditShortUrlCommandTest extends TestCase
$this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier);
$command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier, new MapperBuilder()->mapper());
$this->commandTester = CliTestUtils::testerForCommand($command);
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use CuyZ\Valinor\MapperBuilder;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -34,9 +35,11 @@ class ListShortUrlsCommandTest extends TestCase
protected function setUp(): void
{
$this->shortUrlService = $this->createMock(ShortUrlListServiceInterface::class);
$command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer(
new ShortUrlStringifier(),
));
$command = new ListShortUrlsCommand(
$this->shortUrlService,
new ShortUrlDataTransformer(new ShortUrlStringifier()),
new MapperBuilder()->allowSuperfluousKeys()->mapper(),
);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
@@ -72,7 +75,7 @@ class ListShortUrlsCommandTest extends TestCase
}
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::empty(),
new ShortUrlsParams(),
)->willReturn(new Paginator(new ArrayAdapter($data)));
$this->commandTester->setInputs(['n']);
@@ -107,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase
ShortUrl $shortUrl,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::empty(),
new ShortUrlsParams(),
)->willReturn(new Paginator(new ArrayAdapter([
ShortUrlWithDeps::fromShortUrl($shortUrl),
])));
-49
View File
@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Symfony\Component\Console\Output\OutputInterface;
class InputUtilsTest extends TestCase
{
private MockObject & OutputInterface $input;
protected function setUp(): void
{
$this->input = $this->createMock(OutputInterface::class);
}
#[Test]
#[TestWith([null], 'null')]
#[TestWith([''], 'empty string')]
public function processDateReturnsNullForEmptyDates(string|null $date): void
{
$this->input->expects($this->never())->method('writeln');
self::assertNull(InputUtils::processDate('name', $date, $this->input));
}
#[Test]
public function processDateReturnsAtomFormatedForValidDates(): void
{
$date = '2025-01-20';
$this->input->expects($this->never())->method('writeln');
self::assertEquals(Chronos::parse($date)->toAtomString(), InputUtils::processDate('name', $date, $this->input));
}
#[Test]
public function warningIsPrintedWhenDateIsInvalid(): void
{
$this->input->expects($this->once())->method('writeln')->with(
'<comment>> Ignored provided "name" since its value "invalid" is not a valid date. <</comment>',
);
self::assertNull(InputUtils::processDate('name', 'invalid', $this->input));
}
}
@@ -54,12 +54,9 @@ final readonly class ShortUrlsParams
normalizeOptionalDate($startDate),
normalizeOptionalDate($endDate),
);
// FIXME When using shlink-common, TagsConverter implicitly does an array_unique.
$this->tags = array_unique($tags);
$this->excludeTags = array_unique($excludeTags);
}
public static function empty(): self
{
return new self();
}
}
@@ -42,7 +42,7 @@ class ShortUrlListServiceTest extends TestCase
$this->repo->expects($this->once())->method('findList')->willReturn($list);
$this->repo->expects($this->once())->method('countList')->willReturn(count($list));
$paginator = $this->service->listShortUrls(ShortUrlsParams::empty(), $apiKey);
$paginator = $this->service->listShortUrls(new ShortUrlsParams(), $apiKey);
self::assertCount(4, $paginator);
self::assertCount(4, $paginator->getCurrentPageResults());