Merge pull request #759 from acelaya-forks/feature/improved-tags-endpoint

Feature/improved tags endpoint
This commit is contained in:
Alejandro Celaya 2020-05-08 12:17:32 +02:00 committed by GitHub
commit fbb1c449da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 360 additions and 74 deletions

View file

@ -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. 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. * [#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 #### Changed

View file

@ -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"
}
}
}

View file

@ -14,6 +14,19 @@
"parameters": [ "parameters": [
{ {
"$ref": "../parameters/version.json" "$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": { "responses": {
@ -26,12 +39,20 @@
"properties": { "properties": {
"tags": { "tags": {
"type": "object", "type": "object",
"required": ["data"],
"properties": { "properties": {
"data": { "data": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "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"
}
} }
} }
} }

View file

@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
@ -78,10 +79,10 @@ return [
Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class], Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class], Command\Tag\CreateTagCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Db\CreateDatabaseCommand::class => [ Command\Db\CreateDatabaseCommand::class => [
LockFactory::class, LockFactory::class,

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag; namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; 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\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag; namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; 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\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;

View file

@ -7,7 +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\Entity\Tag; 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\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
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\Service\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\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;

View file

@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View file

@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View file

@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase;
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\Core\Entity\Tag; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View file

@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View file

@ -28,7 +28,7 @@ return [
Service\ShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::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\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::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], Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
Visit\VisitLocator::class => ['em'], Visit\VisitLocator::class => ['em'],
Visit\VisitsStatsHelper::class => ['em'], Visit\VisitsStatsHelper::class => ['em'],
Service\Tag\TagService::class => ['em'], Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => [ Service\ShortUrl\DeleteShortUrlService::class => [
'em', 'em',
Options\DeleteShortUrlsOptions::class, Options\DeleteShortUrlsOptions::class,

View file

@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->setOrderBy(['name' => 'ASC'])
->build(); ->build();
$builder->createManyToOne('domain', Entity\Domain::class) $builder->createManyToOne('domain', Entity\Domain::class)

View file

@ -24,4 +24,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createField('name', Types::STRING) $builder->createField('name', Types::STRING)
->unique() ->unique()
->build(); ->build();
$builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags');
}; };

View file

@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity; namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\Common\Collections;
use JsonSerializable; use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable class Tag extends AbstractEntity implements JsonSerializable
{ {
private string $name; private string $name;
private Collections\Collection $shortUrls;
public function __construct(string $name) public function __construct(string $name)
{ {
$this->name = $name; $this->name = $name;
$this->shortUrls = new Collections\ArrayCollection();
} }
public function rename(string $name): void public function rename(string $name): void

View file

@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use function Functional\map;
class TagRepository extends EntityRepository implements TagRepositoryInterface class TagRepository extends EntityRepository implements TagRepositoryInterface
{ {
@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
return $qb->getQuery()->execute(); return $qb->getQuery()->execute();
} }
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array
{
$dql = <<<DQL
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
FROM Shlinkio\Shlink\Core\Entity\Tag t
LEFT JOIN t.shortUrls s
LEFT JOIN s.visits v
GROUP BY t
ORDER BY t.name ASC
DQL;
$query = $this->getEntityManager()->createQuery($dql);
return map(
$query->getResult(),
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
);
}
} }

View file

@ -5,8 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository; namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagRepositoryInterface extends ObjectRepository interface TagRepositoryInterface extends ObjectRepository
{ {
public function deleteByName(array $names): int; public function deleteByName(array $names): int;
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array;
} }

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use JsonSerializable;
use Shlinkio\Shlink\Core\Entity\Tag;
final class TagInfo implements JsonSerializable
{
private Tag $tag;
private int $shortUrlsCount;
private int $visitsCount;
public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount)
{
$this->tag = $tag;
$this->shortUrlsCount = $shortUrlsCount;
$this->visitsCount = $visitsCount;
}
public function tag(): Tag
{
return $this->tag;
}
public function jsonSerialize(): array
{
return [
'tag' => $this->tag,
'shortUrlsCount' => $this->shortUrlsCount,
'visitsCount' => $this->visitsCount,
];
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM; use Doctrine\ORM;
@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag;
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\Repository\TagRepository; 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; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
class TagService implements TagServiceInterface class TagService implements TagServiceInterface
@ -25,7 +27,6 @@ class TagService implements TagServiceInterface
/** /**
* @return Tag[] * @return Tag[]
* @throws \UnexpectedValueException
*/ */
public function listTags(): array public function listTags(): array
{ {
@ -34,6 +35,16 @@ class TagService implements TagServiceInterface
return $tags; return $tags;
} }
/**
* @return TagInfo[]
*/
public function tagsInfo(): array
{
/** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo();
}
/** /**
* @param string[] $tagNames * @param string[] $tagNames
*/ */

View file

@ -2,12 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
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;
interface TagServiceInterface interface TagServiceInterface
{ {
@ -16,6 +17,11 @@ interface TagServiceInterface
*/ */
public function listTags(): array; public function listTags(): array;
/**
* @return TagInfo[]
*/
public function tagsInfo(): array;
/** /**
* @param string[] $tagNames * @param string[] $tagNames
*/ */

View file

@ -4,13 +4,21 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository; 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\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_chunk;
class TagRepositoryTest extends DatabaseTestCase class TagRepositoryTest extends DatabaseTestCase
{ {
protected const ENTITIES_TO_EMPTY = [ protected const ENTITIES_TO_EMPTY = [
Visit::class,
ShortUrl::class,
Tag::class, Tag::class,
]; ];
@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase
$this->assertEquals(2, $this->repo->deleteByName($toDelete)); $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(),
);
}
} }

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\Tag; namespace ShlinkioTest\Shlink\Core\Service\Tag;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; 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\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Service\Tag\TagService; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagService;
class TagServiceTest extends TestCase class TagServiceTest extends TestCase
{ {
private TagService $service; private TagService $service;
private ObjectProphecy $em; private ObjectProphecy $em;
private ObjectProphecy $repo;
public function setUp(): void public function setUp(): void
{ {
$this->em = $this->prophesize(EntityManagerInterface::class); $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()); $this->service = new TagService($this->em->reveal());
} }
@ -31,36 +35,41 @@ class TagServiceTest extends TestCase
{ {
$expected = [new Tag('foo'), new Tag('bar')]; $expected = [new Tag('foo'), new Tag('bar')];
$repo = $this->prophesize(EntityRepository::class); $find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$result = $this->service->listTags(); $result = $this->service->listTags();
$this->assertEquals($expected, $result); $this->assertEquals($expected, $result);
$find->shouldHaveBeenCalled(); $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 */ /** @test */
public function deleteTagsDelegatesOnRepository(): void public function deleteTagsDelegatesOnRepository(): void
{ {
$repo = $this->prophesize(TagRepository::class); $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->service->deleteTags(['foo', 'bar']); $this->service->deleteTags(['foo', 'bar']);
$delete->shouldHaveBeenCalled(); $delete->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
} }
/** @test */ /** @test */
public function createTagsPersistsEntities(): void public function createTagsPersistsEntities(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null); $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
@ -68,7 +77,6 @@ class TagServiceTest extends TestCase
$this->assertCount(2, $result); $this->assertCount(2, $result);
$find->shouldHaveBeenCalled(); $find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$persist->shouldHaveBeenCalledTimes(2); $persist->shouldHaveBeenCalledTimes(2);
$flush->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled();
} }
@ -76,12 +84,9 @@ class TagServiceTest extends TestCase
/** @test */ /** @test */
public function renameInvalidTagThrowsException(): void public function renameInvalidTagThrowsException(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find->shouldBeCalled(); $find->shouldBeCalled();
$getRepo->shouldBeCalled();
$this->expectException(TagNotFoundException::class); $this->expectException(TagNotFoundException::class);
$this->service->renameTag('foo', 'bar'); $this->service->renameTag('foo', 'bar');
@ -95,10 +100,8 @@ class TagServiceTest extends TestCase
{ {
$expected = new Tag('foo'); $expected = new Tag('foo');
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected);
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected); $countTags = $this->repo->count(Argument::cetera())->willReturn($count);
$countTags = $repo->count(Argument::cetera())->willReturn($count);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$tag = $this->service->renameTag($oldName, $newName); $tag = $this->service->renameTag($oldName, $newName);
@ -106,7 +109,6 @@ class TagServiceTest extends TestCase
$this->assertSame($expected, $tag); $this->assertSame($expected, $tag);
$this->assertEquals($newName, (string) $tag); $this->assertEquals($newName, (string) $tag);
$find->shouldHaveBeenCalled(); $find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled();
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
} }
@ -120,14 +122,11 @@ class TagServiceTest extends TestCase
/** @test */ /** @test */
public function renameTagToAnExistingNameThrowsException(): void public function renameTagToAnExistingNameThrowsException(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $countTags = $this->repo->count(Argument::cetera())->willReturn(1);
$countTags = $repo->count(Argument::cetera())->willReturn(1);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush(Argument::any())->willReturn(null); $flush = $this->em->flush(Argument::any())->willReturn(null);
$find->shouldBeCalled(); $find->shouldBeCalled();
$getRepo->shouldBeCalled();
$countTags->shouldBeCalled(); $countTags->shouldBeCalled();
$flush->shouldNotBeCalled(); $flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class); $this->expectException(TagConflictException::class);

View file

@ -10,6 +10,7 @@ use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@ -65,10 +66,10 @@ return [
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class], Action\Tag\ListTagsAction::class => [TagService::class],
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class], Action\Tag\DeleteTagsAction::class => [TagService::class],
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class], Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [

View file

@ -7,7 +7,7 @@ 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\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class CreateTagsAction extends AbstractRestAction class CreateTagsAction extends AbstractRestAction

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class DeleteTagsAction extends AbstractRestAction class DeleteTagsAction extends AbstractRestAction

View file

@ -7,9 +7,12 @@ 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\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use function Functional\map;
class ListTagsAction extends AbstractRestAction class ListTagsAction extends AbstractRestAction
{ {
protected const ROUTE_PATH = '/tags'; protected const ROUTE_PATH = '/tags';
@ -22,18 +25,26 @@ class ListTagsAction extends AbstractRestAction
$this->tagService = $tagService; $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 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([ return new JsonResponse([
'tags' => [ 'tags' => [
'data' => $this->tagService->listTags(), 'data' => $data,
'stats' => $tagsInfo,
], ],
]); ]);
} }

View file

@ -8,7 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; 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; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class UpdateTagAction extends AbstractRestAction class UpdateTagAction extends AbstractRestAction

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class ListTagsActionTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideQueries
*/
public function expectedListOfTagsIsReturned(array $query, array $expectedTags): void
{
$resp = $this->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,
],
],
]];
}
}

View file

@ -24,6 +24,7 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface
$manager->persist($fooTag); $manager->persist($fooTag);
$barTag = new Tag('bar'); $barTag = new Tag('bar');
$manager->persist($barTag); $manager->persist($barTag);
$manager->persist(new Tag('baz'));
/** @var ShortUrl $abcShortUrl */ /** @var ShortUrl $abcShortUrl */
$abcShortUrl = $this->getReference('abc123_short_url'); $abcShortUrl = $this->getReference('abc123_short_url');

View file

@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction; use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction;
class CreateTagsActionTest extends TestCase class CreateTagsActionTest extends TestCase

View file

@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction;
class DeleteTagsActionTest extends TestCase class DeleteTagsActionTest extends TestCase

View file

@ -4,15 +4,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Tag; namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
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\Tag\ListTagsAction; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use function Shlinkio\Shlink\Common\json_decode;
class ListTagsActionTest extends TestCase class ListTagsActionTest extends TestCase
{ {
private ListTagsAction $action; private ListTagsAction $action;
@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase
$this->action = new ListTagsAction($this->tagService->reveal()); $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([ $this->assertEquals([
'tags' => [ 'tags' => [
'data' => ['foo', 'bar'], 'data' => ['foo', 'bar'],
'stats' => $stats,
], ],
], json_decode((string) $resp->getBody())); ], $payload);
$listTags->shouldHaveBeenCalled(); $tagsInfo->shouldHaveBeenCalled();
} }
} }

View file

@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ValidationException; 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; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction;
class UpdateTagActionTest extends TestCase class UpdateTagActionTest extends TestCase

View file

@ -4,3 +4,4 @@ parameters:
ignoreErrors: ignoreErrors:
- '#AbstractQuery::setParameters()#' - '#AbstractQuery::setParameters()#'
- '#mustRun()#' - '#mustRun()#'
- '#AssociationBuilder::setOrderBy#'