Merge pull request #1302 from acelaya-forks/feature/paginated-tags

Feature/paginated tags
This commit is contained in:
Alejandro Celaya 2022-01-06 11:54:30 +01:00 committed by GitHub
commit ead8cc6cec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 705 additions and 129 deletions

View File

@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
The `short-urls:list` command now accepts a `-i`/`--including-all-tags` flag which behaves the same. The `short-urls:list` command now accepts a `-i`/`--including-all-tags` flag which behaves the same.
* [#1273](https://github.com/shlinkio/shlink/issues/1273) Added support for pagination in tags lists.
For backwards compatibility, lists continue returning all items by default, but the `GET /tags` endpoint now supports `page` and `itemsPerPage` query params, to make sure only a subset of the tags is returned.
This is supported both when invoking the endpoint with and without `withStats=true`.
Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned.
### Changed ### Changed
* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% the original size. * [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% the original size.
* [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4. * [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4.

View File

@ -4,7 +4,10 @@ export DB_DRIVER=postgres
export TEST_ENV=api export TEST_ENV=api
export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"} export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"}
# Reset logs
rm -rf data/log/api-tests rm -rf data/log/api-tests
mkdir data/log/api-tests
touch data/log/api-tests/output.log
# Try to stop server just in case it hanged in last execution # Try to stop server just in case it hanged in last execution
vendor/bin/laminas mezzio:swoole:stop vendor/bin/laminas mezzio:swoole:stop

View File

@ -48,8 +48,8 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^1.0", "pugx/shortid-php": "^1.0",
"ramsey/uuid": "^4.2", "ramsey/uuid": "^4.2",
"shlinkio/shlink-common": "^4.2.1", "shlinkio/shlink-common": "dev-main#0d476fd as 4.3",
"shlinkio/shlink-config": "^1.4", "shlinkio/shlink-config": "^1.5",
"shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-event-dispatcher": "^2.3",
"shlinkio/shlink-importer": "^2.5", "shlinkio/shlink-importer": "^2.5",
"shlinkio/shlink-installer": "dev-develop#a008036 as 7.0", "shlinkio/shlink-installer": "dev-develop#a008036 as 7.0",
@ -95,10 +95,8 @@
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
"ShlinkioTest\\Shlink\\Core\\": [ "ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
"module/Core/test", "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db"
"module/Core/test-db"
]
}, },
"files": [ "files": [
"config/test/constants.php" "config/test/constants.php"
@ -192,6 +190,12 @@
}, },
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"platform-check": false "platform-check": false,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"infection/extension-installer": true,
"veewee/composer-run-parallel": true
}
} }
} }

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink; namespace Shlinkio\Shlink;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return (static function (): array { return (static function (): array {
$threshold = env('DELETE_SHORT_URL_THRESHOLD'); $threshold = env('DELETE_SHORT_URL_THRESHOLD');

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use function Functional\contains; use function Functional\contains;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return (static function (): array { return (static function (): array {
$driver = env('DB_DRIVER'); $driver = env('DB_DRIVER');

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return [ return [

View File

@ -7,7 +7,7 @@ use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory; use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock; use Symfony\Component\Lock;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY; use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;

View File

@ -7,7 +7,7 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return (static function (): array { return (static function (): array {
$publicUrl = env('MERCURE_PUBLIC_HUB_URL'); $publicUrl = env('MERCURE_PUBLIC_HUB_URL');

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;

View File

@ -6,7 +6,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Proxy\LazyServiceFactory; use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection; use PhpAmqpLib\Connection\AMQPStreamConnection;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return [ return [

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return (static function (): array { return (static function (): array {
$redisServers = env('REDIS_SERVERS'); $redisServers = env('REDIS_SERVERS');

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
use Mezzio\Router\FastRouteRouter; use Mezzio\Router\FastRouteRouter;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return [ return [

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const Shlinkio\Shlink\MIN_TASK_WORKERS; use const Shlinkio\Shlink\MIN_TASK_WORKERS;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return [ return [

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
return (static function (): array { return (static function (): array {
$webhooks = env('VISITS_WEBHOOKS'); $webhooks = env('VISITS_WEBHOOKS');

View File

@ -11,7 +11,7 @@ use Mezzio\ProblemDetails;
use Mezzio\Swoole; use Mezzio\Swoole;
use function class_exists; use function class_exists;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use const PHP_SAPI; use const PHP_SAPI;

View File

@ -22,7 +22,7 @@ use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml; use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use function Laminas\Stratigility\middleware; use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Config\env;
use function sprintf; use function sprintf;
use function sys_get_temp_dir; use function sys_get_temp_dir;
@ -109,6 +109,7 @@ return [
'process-name' => 'shlink_test', 'process-name' => 'shlink_test',
'options' => [ 'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
'log_file' => __DIR__ . '/../../data/log/api-tests/output.log',
'enable_coroutine' => false, 'enable_coroutine' => false,
], ],
], ],

View File

@ -17,7 +17,7 @@
}, },
{ {
"name": "withStats", "name": "withStats",
"description": "Whether you want to include also a list with general stats by tag or not.", "description": "Whether you want to include also a list with general stats by tag or not. Defaults to false.",
"in": "query", "in": "query",
"required": false, "required": false,
"schema": { "schema": {
@ -27,6 +27,33 @@
"false" "false"
] ]
} }
},
{
"name": "page",
"in": "query",
"description": "The page to display. Defaults to 1",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to all the items",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "searchTerm",
"in": "query",
"description": "A query used to filter results by searching for it on the tag name.",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -53,6 +80,9 @@
"items": { "items": {
"$ref": "../definitions/TagInfo.json" "$ref": "../definitions/TagInfo.json"
} }
},
"pagination": {
"$ref": "../definitions/Pagination.json"
} }
} }
} }
@ -67,7 +97,14 @@
"php", "php",
"shlink", "shlink",
"tech" "tech"
] ],
"pagination": {
"currentPage": 5,
"pagesCount": 10,
"itemsPerPage": 4,
"itemsInCurrentPage": 4,
"totalItems": 38
}
} }
} }
}, },
@ -89,7 +126,14 @@
"shortUrlsCount": 7, "shortUrlsCount": 7,
"visitsCount": 1087 "visitsCount": 1087
} }
] ],
"pagination": {
"currentPage": 5,
"pagesCount": 5,
"itemsPerPage": 10,
"itemsInCurrentPage": 2,
"totalItems": 42
}
} }
} }
} }

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -38,7 +39,7 @@ class ListTagsCommand extends Command
private function getTagsRows(): array private function getTagsRows(): array
{ {
$tags = $this->tagService->tagsInfo(); $tags = $this->tagService->tagsInfo(TagsParams::fromRawData([]))->getCurrentPageResults();
if (empty($tags)) { if (empty($tags)) {
return [['No tags found', '-', '-']]; return [['No tags found', '-', '-']];
} }

View File

@ -4,9 +4,12 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
@ -29,7 +32,7 @@ class ListTagsCommandTest extends TestCase
/** @test */ /** @test */
public function noTagsPrintsEmptyMessage(): void public function noTagsPrintsEmptyMessage(): void
{ {
$tagsInfo = $this->tagService->tagsInfo()->willReturn([]); $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([]); $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
@ -41,10 +44,10 @@ class ListTagsCommandTest extends TestCase
/** @test */ /** @test */
public function listOfTagsIsPrinted(): void public function listOfTagsIsPrinted(): void
{ {
$tagsInfo = $this->tagService->tagsInfo()->willReturn([ $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([
new TagInfo(new Tag('foo'), 10, 2), new TagInfo(new Tag('foo'), 10, 2),
new TagInfo(new Tag('bar'), 7, 32), new TagInfo(new Tag('bar'), 7, 32),
]); ])));
$this->commandTester->execute([]); $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Common\Paginator\Paginator;
abstract class AbstractInfinitePaginableListParams
{
private const FIRST_PAGE = 1;
private int $page;
private int $itemsPerPage;
protected function __construct(?int $page, ?int $itemsPerPage)
{
$this->page = $this->determinePage($page);
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
}
private function determinePage(?int $page): int
{
return $page === null || $page <= 0 ? self::FIRST_PAGE : $page;
}
private function determineItemsPerPage(?int $itemsPerPage): int
{
return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage;
}
public function getPage(): int
{
return $this->page;
}
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
}

View File

@ -4,49 +4,29 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model; namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
final class VisitsParams final class VisitsParams extends AbstractInfinitePaginableListParams
{ {
private const FIRST_PAGE = 1;
private DateRange $dateRange; private DateRange $dateRange;
private int $page;
private int $itemsPerPage;
public function __construct( public function __construct(
?DateRange $dateRange = null, ?DateRange $dateRange = null,
int $page = self::FIRST_PAGE, ?int $page = null,
?int $itemsPerPage = null, ?int $itemsPerPage = null,
private bool $excludeBots = false, private bool $excludeBots = false,
) { ) {
parent::__construct($page, $itemsPerPage);
$this->dateRange = $dateRange ?? DateRange::emptyInstance(); $this->dateRange = $dateRange ?? DateRange::emptyInstance();
$this->page = $this->determinePage($page);
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
}
private function determinePage(int $page): int
{
return $page > 0 ? $page : self::FIRST_PAGE;
}
private function determineItemsPerPage(?int $itemsPerPage): int
{
if ($itemsPerPage !== null && $itemsPerPage < 0) {
return Paginator::ALL_ITEMS;
}
return $itemsPerPage ?? Paginator::ALL_ITEMS;
} }
public static function fromRawData(array $query): self public static function fromRawData(array $query): self
{ {
return new self( return new self(
parseDateRangeFromQuery($query, 'startDate', 'endDate'), parseDateRangeFromQuery($query, 'startDate', 'endDate'),
(int) ($query['page'] ?? self::FIRST_PAGE), isset($query['page']) ? (int) $query['page'] : null,
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
isset($query['excludeBots']), isset($query['excludeBots']),
); );
@ -57,16 +37,6 @@ final class VisitsParams
return $this->dateRange; return $this->dateRange;
} }
public function getPage(): int
{
return $this->page;
}
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
public function excludeBots(): bool public function excludeBots(): bool
{ {
return $this->excludeBots; return $this->excludeBots;

View File

@ -8,6 +8,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -32,24 +33,31 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function findTagsWithInfo(?ApiKey $apiKey = null): array public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{ {
$qb = $this->createQueryBuilder('t'); $qb = $this->createQueryBuilder('t');
$qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount') $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
->leftJoin('t.shortUrls', 's') ->leftJoin('t.shortUrls', 's')
->leftJoin('s.visits', 'v') ->leftJoin('s.visits', 'v')
->groupBy('t') ->groupBy('t')
->orderBy('t.name', 'ASC'); ->orderBy('t.name', 'ASC')
->setMaxResults($filtering?->limit())
->setFirstResult($filtering?->offset());
$searchTerm = $filtering?->searchTerm();
if ($searchTerm !== null) {
$qb->andWhere($qb->expr()->like('t.name', ':searchPattern'))
->setParameter('searchPattern', '%' . $searchTerm . '%');
}
$apiKey = $filtering?->apiKey();
if ($apiKey !== null) { if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't');
} }
$query = $qb->getQuery();
return map( return map(
$query->getResult(), $qb->getQuery()->getResult(),
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
); );
} }

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
@ -16,7 +17,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function findTagsWithInfo(?ApiKey $apiKey = null): array; public function findTagsWithInfo(?TagsListFiltering $filtering = null): array;
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; public function tagExists(string $tag, ?ApiKey $apiKey = null): bool;
} }

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
final class TagsListFiltering
{
public function __construct(
private ?int $limit = null,
private ?int $offset = null,
private ?string $searchTerm = null,
private ?ApiKey $apiKey = null,
) {
}
public function limit(): ?int
{
return $this->limit;
}
public function offset(): ?int
{
return $this->offset;
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function apiKey(): ?ApiKey
{
return $this->apiKey;
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams;
final class TagsParams extends AbstractInfinitePaginableListParams
{
private function __construct(private ?string $searchTerm, ?int $page, ?int $itemsPerPage)
{
parent::__construct($page, $itemsPerPage);
}
public static function fromRawData(array $query): self
{
return new self(
$query['searchTerm'] ?? null,
isset($query['page']) ? (int) $query['page'] : null,
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
);
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
use Happyr\DoctrineSpecification\Spec;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
{
public function __construct(
protected TagRepositoryInterface $repo,
protected TagsParams $params,
protected ?ApiKey $apiKey,
) {
}
public function getNbResults(): int
{
return (int) $this->repo->matchSingleScalarResult(Spec::andX(
// FIXME I don't think using Spec::selectNew is the correct thing here, ideally it should be Spec::select,
// but seems to be the only way to use Spec::COUNT(...)
Spec::selectNew(Tag::class, Spec::COUNT('id', true)),
new WithApiKeySpecsEnsuringJoin($this->apiKey),
));
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
{
public function getSlice(int $offset, int $length): iterable
{
return $this->repo->findTagsWithInfo(
new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey),
);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
{
public function getSlice(int $offset, int $length): iterable
{
$conditions = [
new WithApiKeySpecsEnsuringJoin($this->apiKey),
Spec::orderBy('name'),
Spec::limit($length),
Spec::offset($offset),
];
$searchTerm = $this->params->searchTerm();
if ($searchTerm !== null) {
$conditions[] = Spec::like('name', $searchTerm);
}
return $this->repo->match(Spec::andX(...$conditions));
}
}

View File

@ -5,7 +5,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\ORM; use Doctrine\ORM;
use Happyr\DoctrineSpecification\Spec; use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
@ -14,7 +15,9 @@ use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagService implements TagServiceInterface class TagService implements TagServiceInterface
@ -24,26 +27,30 @@ class TagService implements TagServiceInterface
} }
/** /**
* @return Tag[] * @return Tag[]|Paginator
*/ */
public function listTags(?ApiKey $apiKey = null): array public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{ {
/** @var TagRepository $repo */ /** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class); $repo = $this->em->getRepository(Tag::class);
return $repo->match(Spec::andX( return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params);
Spec::orderBy('name'),
new WithApiKeySpecsEnsuringJoin($apiKey),
));
} }
/** /**
* @return TagInfo[] * @return TagInfo[]|Paginator
*/ */
public function tagsInfo(?ApiKey $apiKey = null): array public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{ {
/** @var TagRepositoryInterface $repo */ /** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class); $repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo($apiKey); return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
}
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
{
return (new Paginator($adapter))
->setMaxPerPage($params->getItemsPerPage())
->setCurrentPage($params->getPage());
} }
/** /**

View File

@ -4,25 +4,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface interface TagServiceInterface
{ {
/** /**
* @return Tag[] * @return Tag[]|Paginator
*/ */
public function listTags(?ApiKey $apiKey = null): array; public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
/** /**
* @return TagInfo[] * @return TagInfo[]|Paginator
*/ */
public function tagsInfo(?ApiKey $apiKey = null): array; public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
/** /**
* @param string[] $tagNames * @param string[] $tagNames

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Domain\Repository; namespace ShlinkioDbTest\Shlink\Core\Domain\Repository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository; namespace ShlinkioDbTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository; namespace ShlinkioDbTest\Shlink\Core\Repository;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -50,8 +52,11 @@ class TagRepositoryTest extends DatabaseTestCase
self::assertEquals(2, $this->repo->deleteByName($toDelete)); self::assertEquals(2, $this->repo->deleteByName($toDelete));
} }
/** @test */ /**
public function properTagsInfoIsReturned(): void * @test
* @dataProvider provideFilterings
*/
public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void
{ {
$names = ['foo', 'bar', 'baz', 'another']; $names = ['foo', 'bar', 'baz', 'another'];
foreach ($names as $name) { foreach ($names as $name) {
@ -74,24 +79,81 @@ class TagRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
$result = $this->repo->findTagsWithInfo(); $result = $this->repo->findTagsWithInfo($filtering);
self::assertCount(4, $result); $asserts($result, $names);
self::assertEquals(0, $result[0]->shortUrlsCount()); }
self::assertEquals(0, $result[0]->visitsCount());
self::assertEquals($names[3], $result[0]->tag()->__toString());
self::assertEquals(1, $result[1]->shortUrlsCount()); public function provideFilterings(): iterable
self::assertEquals(3, $result[1]->visitsCount()); {
self::assertEquals($names[1], $result[1]->tag()->__toString()); $noFiltersAsserts = static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */
self::assertCount(4, $result);
self::assertEquals(0, $result[0]->shortUrlsCount());
self::assertEquals(0, $result[0]->visitsCount());
self::assertEquals($tagNames[3], $result[0]->tag()->__toString());
self::assertEquals(1, $result[2]->shortUrlsCount()); self::assertEquals(1, $result[1]->shortUrlsCount());
self::assertEquals(3, $result[2]->visitsCount()); self::assertEquals(3, $result[1]->visitsCount());
self::assertEquals($names[2], $result[2]->tag()->__toString()); self::assertEquals($tagNames[1], $result[1]->tag()->__toString());
self::assertEquals(2, $result[3]->shortUrlsCount()); self::assertEquals(1, $result[2]->shortUrlsCount());
self::assertEquals(4, $result[3]->visitsCount()); self::assertEquals(3, $result[2]->visitsCount());
self::assertEquals($names[0], $result[3]->tag()->__toString()); self::assertEquals($tagNames[2], $result[2]->tag()->__toString());
self::assertEquals(2, $result[3]->shortUrlsCount());
self::assertEquals(4, $result[3]->visitsCount());
self::assertEquals($tagNames[0], $result[3]->tag()->__toString());
};
yield 'no filter' => [null, $noFiltersAsserts];
yield 'empty filter' => [new TagsListFiltering(), $noFiltersAsserts];
yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */
self::assertCount(2, $result);
self::assertEquals(0, $result[0]->shortUrlsCount());
self::assertEquals(0, $result[0]->visitsCount());
self::assertEquals($tagNames[3], $result[0]->tag()->__toString());
self::assertEquals(1, $result[1]->shortUrlsCount());
self::assertEquals(3, $result[1]->visitsCount());
self::assertEquals($tagNames[1], $result[1]->tag()->__toString());
}];
yield 'offset' => [new TagsListFiltering(null, 3), static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */
self::assertCount(1, $result);
self::assertEquals(2, $result[0]->shortUrlsCount());
self::assertEquals(4, $result[0]->visitsCount());
self::assertEquals($tagNames[0], $result[0]->tag()->__toString());
}];
yield 'limit and offset' => [
new TagsListFiltering(2, 1),
static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */
self::assertCount(2, $result);
self::assertEquals(1, $result[0]->shortUrlsCount());
self::assertEquals(3, $result[0]->visitsCount());
self::assertEquals($tagNames[1], $result[0]->tag()->__toString());
self::assertEquals(1, $result[1]->shortUrlsCount());
self::assertEquals(3, $result[1]->visitsCount());
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
},
];
yield 'search term' => [
new TagsListFiltering(null, null, 'ba'),
static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */
self::assertCount(2, $result);
self::assertEquals(1, $result[0]->shortUrlsCount());
self::assertEquals(3, $result[0]->visitsCount());
self::assertEquals($tagNames[1], $result[0]->tag()->__toString());
self::assertEquals(1, $result[1]->shortUrlsCount());
self::assertEquals(3, $result[1]->visitsCount());
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
},
];
} }
/** @test */ /** @test */

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository; namespace ShlinkioDbTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use ReflectionObject; use ReflectionObject;

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Tag\Paginator\Adapter;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
class TagsPaginatorAdapterTest extends DatabaseTestCase
{
private TagRepository $repo;
protected function beforeEach(): void
{
$this->repo = $this->getEntityManager()->getRepository(Tag::class);
}
/**
* @test
* @dataProvider provideFilters
*/
public function expectedListOfTagsIsReturned(?string $searchTerm, int $offset, int $length, int $expected): void
{
$names = ['foo', 'bar', 'baz', 'another'];
foreach ($names as $name) {
$this->getEntityManager()->persist(new Tag($name));
}
$this->getEntityManager()->flush();
$adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData(['searchTerm' => $searchTerm]), null);
self::assertCount($expected, $adapter->getSlice($offset, $length));
self::assertEquals(4, $adapter->getNbResults());
}
public function provideFilters(): iterable
{
yield [null, 0, 10, 4];
yield [null, 2, 10, 2];
yield [null, 1, 3, 3];
yield [null, 3, 3, 1];
yield [null, 0, 2, 2];
yield ['ba', 0, 10, 2];
yield ['ba', 0, 1, 1];
yield ['foo', 0, 10, 1];
yield ['a', 0, 10, 3];
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
class TagsInfoPaginatorAdapterTest extends TestCase
{
use ProphecyTrait;
private TagsInfoPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(TagRepositoryInterface::class);
$this->adapter = new TagsInfoPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
}
/** @test */
public function getSliceIsDelegatedToRepository(): void
{
$findTags = $this->repo->findTagsWithInfo(Argument::cetera())->willReturn([]);
$this->adapter->getSlice(1, 1);
$findTags->shouldHaveBeenCalledOnce();
}
/** @test */
public function getNbResultsIsDelegatedToRepository(): void
{
$match = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(3);
$result = $this->adapter->getNbResults();
self::assertEquals(3, $result);
$match->shouldHaveBeenCalledOnce();
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
class TagsPaginatorAdapterTest extends TestCase
{
use ProphecyTrait;
private TagsPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(TagRepositoryInterface::class);
$this->adapter = new TagsPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
}
/** @test */
public function getSliceDelegatesToRepository(): void
{
$match = $this->repo->match(Argument::cetera())->willReturn([]);
$this->adapter->getSlice(1, 1);
$match->shouldHaveBeenCalledOnce();
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\Tag; namespace ShlinkioTest\Shlink\Core\Tag;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@ -46,27 +48,63 @@ class TagServiceTest extends TestCase
$expected = [new Tag('foo'), new Tag('bar')]; $expected = [new Tag('foo'), new Tag('bar')];
$match = $this->repo->match(Argument::cetera())->willReturn($expected); $match = $this->repo->match(Argument::cetera())->willReturn($expected);
$count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
$result = $this->service->listTags(); $result = $this->service->listTags(TagsParams::fromRawData([]));
self::assertEquals($expected, $result); self::assertEquals($expected, $result->getCurrentPageResults());
$match->shouldHaveBeenCalled(); $match->shouldHaveBeenCalled();
$count->shouldHaveBeenCalled();
} }
/** /**
* @test * @test
* @dataProvider provideAdminApiKeys * @dataProvider provideApiKeysAndSearchTerm
*/ */
public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void public function tagsInfoDelegatesOnRepository(
{ ?ApiKey $apiKey,
TagsParams $params,
TagsListFiltering $expectedFiltering,
int $countCalls,
): void {
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
$find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected); $find = $this->repo->findTagsWithInfo($expectedFiltering)->willReturn($expected);
$count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
$result = $this->service->tagsInfo($apiKey); $result = $this->service->tagsInfo($params, $apiKey);
self::assertEquals($expected, $result); self::assertEquals($expected, $result->getCurrentPageResults());
$find->shouldHaveBeenCalled(); $find->shouldHaveBeenCalledOnce();
$count->shouldHaveBeenCalledTimes($countCalls);
}
public function provideApiKeysAndSearchTerm(): iterable
{
yield 'no API key, no filter' => [
null,
TagsParams::fromRawData([]),
new TagsListFiltering(2, 0, null, null),
1,
];
yield 'admin API key, no filter' => [
$apiKey = ApiKey::create(),
TagsParams::fromRawData([]),
new TagsListFiltering(2, 0, null, $apiKey),
1,
];
yield 'no API key, search term' => [
null,
TagsParams::fromRawData(['searchTerm' => $searchTerm = 'foobar']),
new TagsListFiltering(2, 0, $searchTerm, null),
1,
];
yield 'admin API key, limits' => [
$apiKey = ApiKey::create(),
TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]),
new TagsListFiltering(1, 0, null, $apiKey),
0,
];
} }
/** /**

View File

@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@ -16,6 +18,8 @@ use function Functional\map;
class ListTagsAction extends AbstractRestAction class ListTagsAction extends AbstractRestAction
{ {
use PagerfantaUtilsTrait;
protected const ROUTE_PATH = '/tags'; protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
@ -28,23 +32,18 @@ class ListTagsAction extends AbstractRestAction
$query = $request->getQueryParams(); $query = $request->getQueryParams();
$withStats = ($query['withStats'] ?? null) === 'true'; $withStats = ($query['withStats'] ?? null) === 'true';
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$params = TagsParams::fromRawData($query);
if (! $withStats) { if (! $withStats) {
return new JsonResponse([ return new JsonResponse([
'tags' => [ 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
'data' => $this->tagService->listTags($apiKey),
],
]); ]);
} }
$tagsInfo = $this->tagService->tagsInfo($apiKey); $tagsInfo = $this->tagService->tagsInfo($params, $apiKey);
$data = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString()); $rawTags = $this->serializePaginator($tagsInfo, null, 'stats');
$rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString());
return new JsonResponse([ return new JsonResponse(['tags' => $rawTags]);
'tags' => [
'data' => $data,
'stats' => $tagsInfo,
],
]);
} }
} }

View File

@ -25,6 +25,23 @@ class ListTagsTest extends ApiTestCase
{ {
yield 'admin API key without stats' => ['valid_api_key', [], [ yield 'admin API key without stats' => ['valid_api_key', [], [
'data' => ['bar', 'baz', 'foo'], 'data' => ['bar', 'baz', 'foo'],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 3,
'itemsInCurrentPage' => 3,
'totalItems' => 3,
],
]];
yield 'admin api key with pagination' => ['valid_api_key', ['page' => 2, 'itemsPerPage' => 2], [
'data' => ['foo'],
'pagination' => [
'currentPage' => 2,
'pagesCount' => 2,
'itemsPerPage' => 2,
'itemsInCurrentPage' => 1,
'totalItems' => 3,
],
]]; ]];
yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [ yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [
'data' => ['bar', 'baz', 'foo'], 'data' => ['bar', 'baz', 'foo'],
@ -45,10 +62,50 @@ class ListTagsTest extends ApiTestCase
'visitsCount' => 5, 'visitsCount' => 5,
], ],
], ],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 3,
'itemsInCurrentPage' => 3,
'totalItems' => 3,
],
]];
yield 'admin API key with pagination and stats' => ['valid_api_key', [
'withStats' => 'true',
'page' => 1,
'itemsPerPage' => 2,
], [
'data' => ['bar', 'baz'],
'stats' => [
[
'tag' => 'bar',
'shortUrlsCount' => 1,
'visitsCount' => 2,
],
[
'tag' => 'baz',
'shortUrlsCount' => 0,
'visitsCount' => 0,
],
],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 2,
'itemsPerPage' => 2,
'itemsInCurrentPage' => 2,
'totalItems' => 3,
],
]]; ]];
yield 'author API key without stats' => ['author_api_key', [], [ yield 'author API key without stats' => ['author_api_key', [], [
'data' => ['bar', 'foo'], 'data' => ['bar', 'foo'],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 2,
'itemsInCurrentPage' => 2,
'totalItems' => 2,
],
]]; ]];
yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [ yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [
'data' => ['bar', 'foo'], 'data' => ['bar', 'foo'],
@ -64,10 +121,24 @@ class ListTagsTest extends ApiTestCase
'visitsCount' => 5, 'visitsCount' => 5,
], ],
], ],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 2,
'itemsInCurrentPage' => 2,
'totalItems' => 2,
],
]]; ]];
yield 'domain API key without stats' => ['domain_api_key', [], [ yield 'domain API key without stats' => ['domain_api_key', [], [
'data' => ['foo'], 'data' => ['foo'],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 1,
'itemsInCurrentPage' => 1,
'totalItems' => 1,
],
]]; ]];
yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [ yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [
'data' => ['foo'], 'data' => ['foo'],
@ -78,6 +149,13 @@ class ListTagsTest extends ApiTestCase
'visitsCount' => 0, 'visitsCount' => 0,
], ],
], ],
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 1,
'itemsInCurrentPage' => 1,
'totalItems' => 1,
],
]]; ]];
} }
} }

View File

@ -6,17 +6,21 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function count;
class ListTagsActionTest extends TestCase class ListTagsActionTest extends TestCase
{ {
use ProphecyTrait; use ProphecyTrait;
@ -37,7 +41,10 @@ class ListTagsActionTest extends TestCase
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
{ {
$tags = [new Tag('foo'), new Tag('bar')]; $tags = [new Tag('foo'), new Tag('bar')];
$listTags = $this->tagService->listTags(Argument::type(ApiKey::class))->willReturn($tags); $tagsCount = count($tags);
$listTags = $this->tagService->listTags(Argument::any(), Argument::type(ApiKey::class))->willReturn(
new Paginator(new ArrayAdapter($tags)),
);
/** @var JsonResponse $resp */ /** @var JsonResponse $resp */
$resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query));
@ -46,6 +53,13 @@ class ListTagsActionTest extends TestCase
self::assertEquals([ self::assertEquals([
'tags' => [ 'tags' => [
'data' => $tags, 'data' => $tags,
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 10,
'itemsInCurrentPage' => $tagsCount,
'totalItems' => $tagsCount,
],
], ],
], $payload); ], $payload);
$listTags->shouldHaveBeenCalled(); $listTags->shouldHaveBeenCalled();
@ -65,7 +79,10 @@ class ListTagsActionTest extends TestCase
new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('foo'), 1, 1),
new TagInfo(new Tag('bar'), 3, 10), new TagInfo(new Tag('bar'), 3, 10),
]; ];
$tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats); $itemsCount = count($stats);
$tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn(
new Paginator(new ArrayAdapter($stats)),
);
$req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
/** @var JsonResponse $resp */ /** @var JsonResponse $resp */
@ -76,6 +93,13 @@ class ListTagsActionTest extends TestCase
'tags' => [ 'tags' => [
'data' => ['foo', 'bar'], 'data' => ['foo', 'bar'],
'stats' => $stats, 'stats' => $stats,
'pagination' => [
'currentPage' => 1,
'pagesCount' => 1,
'itemsPerPage' => 10,
'itemsInCurrentPage' => $itemsCount,
'totalItems' => $itemsCount,
],
], ],
], $payload); ], $payload);
$tagsInfo->shouldHaveBeenCalled(); $tagsInfo->shouldHaveBeenCalled();