From 7e0a14493e4b6b4db945adb45a82caf5fbb0b499 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 10:14:38 +0200 Subject: [PATCH 1/9] Documented updates on the tags endpoint to return more detailed information --- docs/swagger/definitions/TagInfo.json | 17 +++++++++++++++ docs/swagger/paths/v1_tags.json | 21 +++++++++++++++++++ .../Core/src/{Service => }/Tag/TagService.php | 0 .../{Service => }/Tag/TagServiceInterface.php | 0 4 files changed, 38 insertions(+) create mode 100644 docs/swagger/definitions/TagInfo.json rename module/Core/src/{Service => }/Tag/TagService.php (100%) rename module/Core/src/{Service => }/Tag/TagServiceInterface.php (100%) diff --git a/docs/swagger/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json new file mode 100644 index 00000000..e881ce02 --- /dev/null +++ b/docs/swagger/definitions/TagInfo.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "The unique tag name" + }, + "shortUrlsCount": { + "type": "number", + "description": "The amount of short URLs using this tag" + }, + "userAgent": { + "type": "number", + "description": "The combined amount of visits received by short URLs with this tag" + } + } +} diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 5e7fd71c..83bc7d68 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -14,6 +14,19 @@ "parameters": [ { "$ref": "../parameters/version.json" + }, + { + "name": "withStats", + "description": "Whether you want to include also a list with general stats by tag or not.", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } } ], "responses": { @@ -26,12 +39,20 @@ "properties": { "tags": { "type": "object", + "required": ["data"], "properties": { "data": { "type": "array", "items": { "type": "string" } + }, + "stats": { + "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", + "type": "array", + "items": { + "$ref": "../definitions/TagInfo.json" + } } } } diff --git a/module/Core/src/Service/Tag/TagService.php b/module/Core/src/Tag/TagService.php similarity index 100% rename from module/Core/src/Service/Tag/TagService.php rename to module/Core/src/Tag/TagService.php diff --git a/module/Core/src/Service/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php similarity index 100% rename from module/Core/src/Service/Tag/TagServiceInterface.php rename to module/Core/src/Tag/TagServiceInterface.php From 626c92460bcd1d157a70cfb5d6e3bceb18110f47 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 10:15:33 +0200 Subject: [PATCH 2/9] Enhanced list tags endpoint so that it can also return stats foir every tag --- module/CLI/config/dependencies.config.php | 8 ++-- .../CLI/src/Command/Tag/CreateTagCommand.php | 2 +- .../CLI/src/Command/Tag/DeleteTagsCommand.php | 6 +-- .../CLI/src/Command/Tag/ListTagsCommand.php | 2 +- .../CLI/src/Command/Tag/RenameTagCommand.php | 4 +- .../test/Command/Tag/CreateTagCommandTest.php | 2 +- .../Command/Tag/DeleteTagsCommandTest.php | 4 +- .../test/Command/Tag/ListTagsCommandTest.php | 4 +- .../test/Command/Tag/RenameTagCommandTest.php | 2 +- module/Core/config/dependencies.config.php | 4 +- .../Shlinkio.Shlink.Core.Entity.Tag.php | 6 +++ module/Core/src/Entity/Tag.php | 3 ++ module/Core/src/Repository/TagRepository.php | 23 ++++++++++ .../src/Repository/TagRepositoryInterface.php | 6 +++ module/Core/src/Tag/Model/TagInfo.php | 46 +++++++++++++++++++ module/Core/src/Tag/TagService.php | 15 +++++- module/Core/src/Tag/TagServiceInterface.php | 8 +++- .../Core/test/Service/Tag/TagServiceTest.php | 2 +- module/Rest/config/dependencies.config.php | 8 ++-- .../Rest/src/Action/Tag/CreateTagsAction.php | 2 +- .../Rest/src/Action/Tag/DeleteTagsAction.php | 4 +- module/Rest/src/Action/Tag/ListTagsAction.php | 29 ++++++++---- .../Rest/src/Action/Tag/UpdateTagAction.php | 4 +- .../test/Action/Tag/CreateTagsActionTest.php | 2 +- .../test/Action/Tag/DeleteTagsActionTest.php | 2 +- .../test/Action/Tag/ListTagsActionTest.php | 4 +- .../test/Action/Tag/UpdateTagActionTest.php | 4 +- 27 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 module/Core/src/Tag/Model/TagInfo.php diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 0f2e70a5..1231a9b3 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -78,10 +78,10 @@ return [ Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], - Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class], - Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class], - Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class], - Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class], + Command\Tag\ListTagsCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Command\Tag\CreateTagCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Command\Tag\RenameTagCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Command\Tag\DeleteTagsCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 5fe56d46..451eb81e 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 1cebe895..f50f835a 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -16,9 +16,9 @@ class DeleteTagsCommand extends Command { public const NAME = 'tag:delete'; - private TagServiceInterface $tagService; + private \Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService) + public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) { parent::__construct(); $this->tagService = $tagService; diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 0b8f0aa3..5a8389f3 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index f30bc757..2a4a1245 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -20,7 +20,7 @@ class RenameTagCommand extends Command private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService) + public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) { parent::__construct(); $this->tagService = $tagService; diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index bed087a5..e156cf28 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 060e5aac..1ec75c8f 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -18,7 +18,7 @@ class DeleteTagsCommandTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); + $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); $command = new DeleteTagsCommand($this->tagService->reveal()); $app = new Application(); diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index f171127c..b3914916 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -19,7 +19,7 @@ class ListTagsCommandTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); + $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); $command = new ListTagsCommand($this->tagService->reveal()); $app = new Application(); diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 59f8d89c..ee499c48 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 67d18c40..5db524b8 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -28,7 +28,7 @@ return [ Service\ShortUrlService::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, - Service\Tag\TagService::class => ConfigAbstractFactory::class, + Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, @@ -58,7 +58,7 @@ return [ Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class], Visit\VisitLocator::class => ['em'], Visit\VisitsStatsHelper::class => ['em'], - Service\Tag\TagService::class => ['em'], + Tag\TagService::class => ['em'], Service\ShortUrl\DeleteShortUrlService::class => [ 'em', Options\DeleteShortUrlsOptions::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php index 214396bd..c3104a9d 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php @@ -24,4 +24,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('name', Types::STRING) ->unique() ->build(); + + $builder->createManyToMany('shortUrls', Entity\ShortUrl::class) + ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) + ->addInverseJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->addJoinColumn('tag_id', 'id', true, false, 'CASCADE') + ->build(); }; diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 7530b70a..54c05c56 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Entity; +use Doctrine\Common\Collections; use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; class Tag extends AbstractEntity implements JsonSerializable { private string $name; + private Collections\Collection $shortUrls; public function __construct(string $name) { $this->name = $name; + $this->shortUrls = new Collections\ArrayCollection(); } public function rename(string $name): void diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 92328630..6f4cbf8c 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use function Functional\map; class TagRepository extends EntityRepository implements TagRepositoryInterface { @@ -21,4 +23,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface return $qb->getQuery()->execute(); } + + /** + * @return TagInfo[] + */ + public function findTagsWithInfo(): array + { + $dql = <<getEntityManager()->createQuery($dql); + + return map( + $query->getResult(), + fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), + ); + } } diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index e253f7a4..37179e21 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -5,8 +5,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; interface TagRepositoryInterface extends ObjectRepository { public function deleteByName(array $names): int; + + /** + * @return TagInfo[] + */ + public function findTagsWithInfo(): array; } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php new file mode 100644 index 00000000..dbc51316 --- /dev/null +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -0,0 +1,46 @@ +tag = $tag; + $this->shortUrlsCount = $shortUrlsCount; + $this->visitsCount = $visitsCount; + } + + public function tag(): Tag + { + return $this->tag; + } + + public function shortUrlsCount(): int + { + return $this->shortUrlsCount; + } + + public function visitsCount(): int + { + return $this->visitsCount; + } + + public function jsonSerialize(): array + { + return [ + 'tag' => $this->tag, + 'shortUrlsCount' => $this->shortUrlsCount, + 'visitsCount' => $this->visitsCount, + ]; + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index b95ddf82..7137e885 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service\Tag; +namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Doctrine\ORM; @@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Util\TagManagerTrait; class TagService implements TagServiceInterface @@ -25,7 +27,6 @@ class TagService implements TagServiceInterface /** * @return Tag[] - * @throws \UnexpectedValueException */ public function listTags(): array { @@ -34,6 +35,16 @@ class TagService implements TagServiceInterface return $tags; } + /** + * @return TagInfo[] + */ + public function tagsInfo(): array + { + /** @var TagRepositoryInterface $repo */ + $repo = $this->em->getRepository(Tag::class); + return $repo->findTagsWithInfo(); + } + /** * @param string[] $tagNames */ diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 16da503c..ed643fc5 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service\Tag; +namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; interface TagServiceInterface { @@ -16,6 +17,11 @@ interface TagServiceInterface */ public function listTags(): array; + /** + * @return TagInfo[] + */ + public function tagsInfo(): array; + /** * @param string[] $tagNames */ diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index b8c9d59b..bd9c447f 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; -use Shlinkio\Shlink\Core\Service\Tag\TagService; +use Shlinkio\Shlink\Core\Tag\TagService; class TagServiceTest extends TestCase { diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index bd347897..c7623ff7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -65,10 +65,10 @@ return [ Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], - Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class], - Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class], - Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class], - Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class], + Action\Tag\ListTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Action\Tag\DeleteTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Action\Tag\CreateTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Action\Tag\UpdateTagAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index c481b463..08f617c2 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class CreateTagsAction extends AbstractRestAction diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index 5002eba0..62d13a16 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class DeleteTagsAction extends AbstractRestAction @@ -17,7 +17,7 @@ class DeleteTagsAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService) + public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) { $this->tagService = $tagService; } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 7211bce6..0832f17c 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use function Functional\map; + class ListTagsAction extends AbstractRestAction { protected const ROUTE_PATH = '/tags'; @@ -22,18 +25,26 @@ class ListTagsAction extends AbstractRestAction $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + $query = $request->getQueryParams(); + $withStats = ($query['withStats'] ?? null) === 'true'; + + if (! $withStats) { + return new JsonResponse([ + 'tags' => [ + 'data' => $this->tagService->listTags(), + ], + ]); + } + + $tagsInfo = $this->tagService->tagsInfo(); + $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); + return new JsonResponse([ 'tags' => [ - 'data' => $this->tagService->listTags(), + 'data' => $data, + 'stats' => $tagsInfo, ], ]); } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index de5eb476..34924f78 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -8,7 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class UpdateTagAction extends AbstractRestAction @@ -16,7 +16,7 @@ class UpdateTagAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private TagServiceInterface $tagService; + private \Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService; public function __construct(TagServiceInterface $tagService) { diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php index 357abc5d..33aa0ba7 100644 --- a/module/Rest/test/Action/Tag/CreateTagsActionTest.php +++ b/module/Rest/test/Action/Tag/CreateTagsActionTest.php @@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction; class CreateTagsActionTest extends TestCase diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 484bd549..819a608a 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; class DeleteTagsActionTest extends TestCase diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 7e9b061f..daacb657 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -8,7 +8,7 @@ use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; use function Shlinkio\Shlink\Common\json_decode; @@ -20,7 +20,7 @@ class ListTagsActionTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); + $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); $this->action = new ListTagsAction($this->tagService->reveal()); } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index ab09b4ea..7d865642 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; class UpdateTagActionTest extends TestCase @@ -19,7 +19,7 @@ class UpdateTagActionTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); + $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); $this->action = new UpdateTagAction($this->tagService->reveal()); } From 9a78fd1a261441919daffabbe44c9d5f0905fe67 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 10:25:33 +0200 Subject: [PATCH 3/9] Fixed definition of inversed many to many entity relationship --- .../entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php index c3104a9d..97d15758 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php @@ -25,9 +25,5 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->unique() ->build(); - $builder->createManyToMany('shortUrls', Entity\ShortUrl::class) - ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) - ->addInverseJoinColumn('short_url_id', 'id', true, false, 'CASCADE') - ->addJoinColumn('tag_id', 'id', true, false, 'CASCADE') - ->build(); + $builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags'); }; From 06c59fe2dddaa05e65e19cb18a1a35b8339e4e5b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 10:29:24 +0200 Subject: [PATCH 4/9] Fixed invalid imports after class refactoring --- module/CLI/config/dependencies.config.php | 9 +++++---- module/CLI/src/Command/Tag/DeleteTagsCommand.php | 4 ++-- module/CLI/src/Command/Tag/RenameTagCommand.php | 2 +- module/CLI/test/Command/Tag/DeleteTagsCommandTest.php | 2 +- module/CLI/test/Command/Tag/ListTagsCommandTest.php | 2 +- module/Core/src/Repository/TagRepository.php | 1 + module/Rest/config/dependencies.config.php | 9 +++++---- module/Rest/src/Action/Tag/DeleteTagsAction.php | 2 +- module/Rest/src/Action/Tag/UpdateTagAction.php | 2 +- module/Rest/test/Action/Tag/ListTagsActionTest.php | 2 +- module/Rest/test/Action/Tag/UpdateTagActionTest.php | 2 +- 11 files changed, 20 insertions(+), 17 deletions(-) diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 1231a9b3..516bbbd4 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; @@ -78,10 +79,10 @@ return [ Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], - Command\Tag\ListTagsCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Command\Tag\CreateTagCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Command\Tag\RenameTagCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Command\Tag\DeleteTagsCommand::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Command\Tag\ListTagsCommand::class => [TagService::class], + Command\Tag\CreateTagCommand::class => [TagService::class], + Command\Tag\RenameTagCommand::class => [TagService::class], + Command\Tag\DeleteTagsCommand::class => [TagService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index f50f835a..2b3eae14 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -16,9 +16,9 @@ class DeleteTagsCommand extends Command { public const NAME = 'tag:delete'; - private \Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService; + private TagServiceInterface $tagService; - public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) + public function __construct(TagServiceInterface $tagService) { parent::__construct(); $this->tagService = $tagService; diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 2a4a1245..fe42a832 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -20,7 +20,7 @@ class RenameTagCommand extends Command private TagServiceInterface $tagService; - public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) + public function __construct(TagServiceInterface $tagService) { parent::__construct(); $this->tagService = $tagService; diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 1ec75c8f..27a95de8 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -18,7 +18,7 @@ class DeleteTagsCommandTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); + $this->tagService = $this->prophesize(TagServiceInterface::class); $command = new DeleteTagsCommand($this->tagService->reveal()); $app = new Application(); diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index b3914916..4318e906 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -19,7 +19,7 @@ class ListTagsCommandTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); + $this->tagService = $this->prophesize(TagServiceInterface::class); $command = new ListTagsCommand($this->tagService->reveal()); $app = new Application(); diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 6f4cbf8c..25c05596 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; + use function Functional\map; class TagRepository extends EntityRepository implements TagRepositoryInterface diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index c7623ff7..a10fd254 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -10,6 +10,7 @@ use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -65,10 +66,10 @@ return [ Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], - Action\Tag\ListTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Action\Tag\DeleteTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Action\Tag\CreateTagsAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], - Action\Tag\UpdateTagAction::class => [\Shlinkio\Shlink\Core\Tag\TagService::class], + Action\Tag\ListTagsAction::class => [TagService::class], + Action\Tag\DeleteTagsAction::class => [TagService::class], + Action\Tag\CreateTagsAction::class => [TagService::class], + Action\Tag\UpdateTagAction::class => [TagService::class], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index 62d13a16..f38c443a 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -17,7 +17,7 @@ class DeleteTagsAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(\Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService) + public function __construct(TagServiceInterface $tagService) { $this->tagService = $tagService; } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 34924f78..fbf93f50 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -16,7 +16,7 @@ class UpdateTagAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private \Shlinkio\Shlink\Core\Tag\TagServiceInterface $tagService; + private TagServiceInterface $tagService; public function __construct(TagServiceInterface $tagService) { diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index daacb657..813d62f4 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -20,7 +20,7 @@ class ListTagsActionTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); + $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new ListTagsAction($this->tagService->reveal()); } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 7d865642..11b2c1c4 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -19,7 +19,7 @@ class UpdateTagActionTest extends TestCase public function setUp(): void { - $this->tagService = $this->prophesize(\Shlinkio\Shlink\Core\Tag\TagServiceInterface::class); + $this->tagService = $this->prophesize(TagServiceInterface::class); $this->action = new UpdateTagAction($this->tagService->reveal()); } From bdd14427d921c9969e9b8ffabc51f4339476cedd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 11:09:28 +0200 Subject: [PATCH 5/9] Added tests for TagRepository::findTagsWithInfo --- module/Core/src/Repository/TagRepository.php | 2 +- .../test-db/Repository/TagRepositoryTest.php | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 25c05596..05b2481c 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -35,7 +35,7 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface FROM Shlinkio\Shlink\Core\Entity\Tag t LEFT JOIN t.shortUrls s LEFT JOIN s.visits v - GROUP BY tag + GROUP BY t ORDER BY t.name ASC DQL; $query = $this->getEntityManager()->createQuery($dql); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 94e38f53..8e1a11ef 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -4,13 +4,21 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; +use Doctrine\Common\Collections\ArrayCollection; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; +use function array_chunk; + class TagRepositoryTest extends DatabaseTestCase { protected const ENTITIES_TO_EMPTY = [ + Visit::class, + ShortUrl::class, Tag::class, ]; @@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase $this->assertEquals(2, $this->repo->deleteByName($toDelete)); } + + /** @test */ + public function properTagsInfoIsReturned(): void + { + $names = ['foo', 'bar', 'baz', 'another']; + $tags = []; + foreach ($names as $name) { + $tag = new Tag($name); + $tags[] = $tag; + $this->getEntityManager()->persist($tag); + } + + [$firstUrlTags] = array_chunk($tags, 3); + $secondUrlTags = [$tags[0]]; + + $shortUrl = new ShortUrl(''); + $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $this->getEntityManager()->persist($shortUrl); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + + $shortUrl2 = new ShortUrl(''); + $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); + $this->getEntityManager()->persist($shortUrl2); + $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance())); + + $this->getEntityManager()->flush(); + + $result = $this->repo->findTagsWithInfo(); + + $this->assertCount(4, $result); + $this->assertEquals( + ['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0], + $result[0]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3], + $result[1]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3], + $result[2]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4], + $result[3]->jsonSerialize(), + ); + } } From 2e269bcacd36994be5756858239e55690d3a82ca Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 11:14:39 +0200 Subject: [PATCH 6/9] Updated TagServiceTest --- .../Core/test/Service/Tag/TagServiceTest.php | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index bd9c447f..c031e51f 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Service\Tag; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; @@ -13,16 +12,21 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagService; class TagServiceTest extends TestCase { private TagService $service; private ObjectProphecy $em; + private ObjectProphecy $repo; public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); + $this->repo = $this->prophesize(TagRepository::class); + $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled(); + $this->service = new TagService($this->em->reveal()); } @@ -31,36 +35,41 @@ class TagServiceTest extends TestCase { $expected = [new Tag('foo'), new Tag('bar')]; - $repo = $this->prophesize(EntityRepository::class); - $find = $repo->findBy(Argument::cetera())->willReturn($expected); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findBy(Argument::cetera())->willReturn($expected); $result = $this->service->listTags(); $this->assertEquals($expected, $result); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); + } + + /** @test */ + public function tagsInfoDelegatesOnRepository(): void + { + $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; + + $find = $this->repo->findTagsWithInfo()->willReturn($expected); + + $result = $this->service->tagsInfo(); + + $this->assertEquals($expected, $result); + $find->shouldHaveBeenCalled(); } /** @test */ public function deleteTagsDelegatesOnRepository(): void { - $repo = $this->prophesize(TagRepository::class); - $delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4); $this->service->deleteTags(['foo', 'bar']); $delete->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); } /** @test */ public function createTagsPersistsEntities(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); @@ -68,7 +77,6 @@ class TagServiceTest extends TestCase $this->assertCount(2, $result); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); $persist->shouldHaveBeenCalledTimes(2); $flush->shouldHaveBeenCalled(); } @@ -76,12 +84,9 @@ class TagServiceTest extends TestCase /** @test */ public function renameInvalidTagThrowsException(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(null); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null); $find->shouldBeCalled(); - $getRepo->shouldBeCalled(); $this->expectException(TagNotFoundException::class); $this->service->renameTag('foo', 'bar'); @@ -95,10 +100,8 @@ class TagServiceTest extends TestCase { $expected = new Tag('foo'); - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn($expected); - $countTags = $repo->count(Argument::cetera())->willReturn($count); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected); + $countTags = $this->repo->count(Argument::cetera())->willReturn($count); $flush = $this->em->flush()->willReturn(null); $tag = $this->service->renameTag($oldName, $newName); @@ -106,7 +109,6 @@ class TagServiceTest extends TestCase $this->assertSame($expected, $tag); $this->assertEquals($newName, (string) $tag); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); } @@ -120,14 +122,11 @@ class TagServiceTest extends TestCase /** @test */ public function renameTagToAnExistingNameThrowsException(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); - $countTags = $repo->count(Argument::cetera())->willReturn(1); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); + $countTags = $this->repo->count(Argument::cetera())->willReturn(1); $flush = $this->em->flush(Argument::any())->willReturn(null); $find->shouldBeCalled(); - $getRepo->shouldBeCalled(); $countTags->shouldBeCalled(); $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); From 91aaffc6db942e5a20b29f5845cdd68bdfafaa73 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 11:32:06 +0200 Subject: [PATCH 7/9] Updated ListTagsActionTest --- module/Core/src/Tag/Model/TagInfo.php | 10 ---- .../test/Action/Tag/ListTagsActionTest.php | 53 +++++++++++++++---- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index dbc51316..0237f062 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -25,16 +25,6 @@ final class TagInfo implements JsonSerializable return $this->tag; } - public function shortUrlsCount(): int - { - return $this->shortUrlsCount; - } - - public function visitsCount(): int - { - return $this->visitsCount; - } - public function jsonSerialize(): array { return [ diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 813d62f4..461ddd3f 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; -use function Shlinkio\Shlink\Common\json_decode; - class ListTagsActionTest extends TestCase { private ListTagsAction $action; @@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase $this->action = new ListTagsAction($this->tagService->reveal()); } - /** @test */ - public function returnsDataFromService(): void + /** + * @test + * @dataProvider provideNoStatsQueries + */ + public function returnsBaseDataWhenStatsAreNotRequested(array $query): void { - $listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]); + $tags = [new Tag('foo'), new Tag('bar')]; + $listTags = $this->tagService->listTags()->willReturn($tags); - $resp = $this->action->handle(new ServerRequest()); + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query)); + $payload = $resp->getPayload(); + + $this->assertEquals([ + 'tags' => [ + 'data' => $tags, + ], + ], $payload); + $listTags->shouldHaveBeenCalled(); + } + + public function provideNoStatsQueries(): iterable + { + yield 'no query' => [[]]; + yield 'withStats is false' => [['withStats' => 'withStats']]; + yield 'withStats is something else' => [['withStats' => 'foo']]; + } + + /** @test */ + public function returnsStatsWhenRequested(): void + { + $stats = [ + new TagInfo(new Tag('foo'), 1, 1), + new TagInfo(new Tag('bar'), 3, 10), + ]; + $tagsInfo = $this->tagService->tagsInfo()->willReturn($stats); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); + $payload = $resp->getPayload(); $this->assertEquals([ 'tags' => [ 'data' => ['foo', 'bar'], + 'stats' => $stats, ], - ], json_decode((string) $resp->getBody())); - $listTags->shouldHaveBeenCalled(); + ], $payload); + $tagsInfo->shouldHaveBeenCalled(); } } From 00cac4ba720c641f0f817b4fbf4228d9b74c61e8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 11:48:31 +0200 Subject: [PATCH 8/9] Created rest test for list tags action --- .../Shlinkio.Shlink.Core.Entity.ShortUrl.php | 1 + .../test-api/Action/ListTagsActionTest.php | 50 +++++++++++++++++++ module/Rest/test-api/Fixtures/TagsFixture.php | 1 + phpstan.neon | 1 + 4 files changed, 53 insertions(+) create mode 100644 module/Rest/test-api/Action/ListTagsActionTest.php diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php index a4aef29f..871ac113 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php @@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->setOrderBy(['name' => 'ASC']) ->build(); $builder->createManyToOne('domain', Entity\Domain::class) diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php new file mode 100644 index 00000000..0690d4f2 --- /dev/null +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -0,0 +1,50 @@ +callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]); + $payload = $this->getJsonResponsePayload($resp); + + $this->assertEquals(['tags' => $expectedTags], $payload); + } + + public function provideQueries(): iterable + { + yield 'stats not requested' => [[], [ + 'data' => ['bar', 'baz', 'foo'], + ]]; + yield 'stats requested' => [['withStats' => 'true'], [ + 'data' => ['bar', 'baz', 'foo'], + 'stats' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'baz', + 'shortUrlsCount' => 0, + 'visitsCount' => 0, + ], + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, + ], + ], + ]]; + } +} diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index 5bd10ca7..5d3333cc 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -24,6 +24,7 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface $manager->persist($fooTag); $barTag = new Tag('bar'); $manager->persist($barTag); + $manager->persist(new Tag('baz')); /** @var ShortUrl $abcShortUrl */ $abcShortUrl = $this->getReference('abc123_short_url'); diff --git a/phpstan.neon b/phpstan.neon index d983a985..e065acef 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,4 @@ parameters: ignoreErrors: - '#AbstractQuery::setParameters()#' - '#mustRun()#' + - '#AssociationBuilder::setOrderBy#' From 252cc7f49d88373cdb01748c32d8901a23ff8151 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 8 May 2020 11:53:26 +0200 Subject: [PATCH 9/9] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fa8531..49fc0a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates. * [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats. +* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag. + + Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag. #### Changed