From 0670a4dc3c496b4a8d5cefca69aa40ce5da82143 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 05:46:15 +0100 Subject: [PATCH 001/115] Added package fixing PHP 8 error --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 9ef7dc73..bf588bba 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "php": "^7.4", "ext-json": "*", "ext-pdo": "*", + "acelaya/qrcode-detector-decoder": "^1.0", "akrabat/ip-address-middleware": "^2.0", "cakephp/chronos": "^2.0", "cocur/slugify": "^4.0", From 823242a6c2e73cc319ac5a2f349ee146c993cf66 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 06:01:12 +0100 Subject: [PATCH 002/115] Updated endroid --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bf588bba..60dbc94a 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "doctrine/cache": "^1.9", "doctrine/migrations": "^3.0.2", "doctrine/orm": "^2.8", - "endroid/qr-code": "^3.6", + "endroid/qr-code": "dev-master#0f1613a as 3.10", "geoip2/geoip2": "^2.9", "guzzlehttp/guzzle": "^7.0", "happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0", From a61235a5d1252fdff55beb1af33679f48145ce3b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 06:07:16 +0100 Subject: [PATCH 003/115] Removed dependency on acelaya/qrcode-detector-decoder --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 60dbc94a..f47cb296 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ "php": "^7.4", "ext-json": "*", "ext-pdo": "*", - "acelaya/qrcode-detector-decoder": "^1.0", "akrabat/ip-address-middleware": "^2.0", "cakephp/chronos": "^2.0", "cocur/slugify": "^4.0", From 80012b8ee86ee0fa989e1c64a6029aadd463880f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 06:16:04 +0100 Subject: [PATCH 004/115] Do not allow unit tests to fail --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c426f4a3..de1064d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,6 @@ jobs: unit-tests: runs-on: ubuntu-20.04 - continue-on-error: ${{ matrix.php-version == '8.0' }} strategy: matrix: php-version: ['7.4', '8.0'] From 55ddc4ae75113ff06cde1d24c0590d4621851296 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 14:37:34 +0100 Subject: [PATCH 005/115] Replaced laminas-paginator with pagerfanta --- composer.json | 4 ++-- .../CLI/src/Command/ShortUrl/GetVisitsCommand.php | 2 +- .../src/Command/ShortUrl/ListShortUrlsCommand.php | 8 ++++---- .../test/Command/ShortUrl/GetVisitsCommandTest.php | 4 ++-- .../Command/ShortUrl/ListShortUrlsCommandTest.php | 14 +++++++------- .../AbstractCacheableCountPaginatorAdapter.php | 4 ++-- .../Adapter/ShortUrlRepositoryAdapter.php | 8 ++++---- .../Adapter/VisitsForTagPaginatorAdapter.php | 4 ++-- .../Paginator/Adapter/VisitsPaginatorAdapter.php | 4 ++-- module/Core/src/Service/ShortUrlService.php | 6 +++--- .../Core/src/Service/ShortUrlServiceInterface.php | 2 +- module/Core/src/Service/VisitsTracker.php | 10 +++++----- module/Core/src/Service/VisitsTrackerInterface.php | 2 +- .../Adapter/ShortUrlRepositoryAdapterTest.php | 4 ++-- .../Adapter/VisitsForTagPaginatorAdapterTest.php | 4 ++-- .../Adapter/VisitsPaginatorAdapterTest.php | 4 ++-- module/Core/test/Service/ShortUrlServiceTest.php | 6 ++++-- module/Core/test/Service/VisitsTrackerTest.php | 4 ++-- .../src/Action/ShortUrl/ListShortUrlsAction.php | 4 ++-- .../Rest/src/Action/Visit/ShortUrlVisitsAction.php | 4 ++-- module/Rest/src/Action/Visit/TagVisitsAction.php | 4 ++-- .../test-api/Action/ShortUrlVisitsActionTest.php | 4 ++-- .../Action/ShortUrl/ListShortUrlsActionTest.php | 6 +++--- .../test/Action/Visit/ShortUrlVisitsActionTest.php | 4 ++-- .../Rest/test/Action/Visit/TagVisitsActionTest.php | 4 ++-- 25 files changed, 63 insertions(+), 61 deletions(-) diff --git a/composer.json b/composer.json index f47cb296..af175a90 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "laminas/laminas-config-aggregator": "^1.1", "laminas/laminas-diactoros": "^2.1.3", "laminas/laminas-inputfilter": "^2.10", - "laminas/laminas-paginator": "^2.8", "laminas/laminas-servicemanager": "^3.6", "laminas/laminas-stdlib": "^3.2", "lcobucci/jwt": "^4.0", @@ -43,11 +42,12 @@ "monolog/monolog": "^2.0", "nikolaposa/monolog-factory": "^3.1", "ocramius/proxy-manager": "^2.11", + "pagerfanta/core": "^2.5", "php-middleware/request-id": "^4.1", "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "^3.4", + "shlinkio/shlink-common": "dev-main#cab9f39 as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.1", diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index a0c2c91a..b58ea3ac 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -75,7 +75,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand $paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate))); - $rows = map($paginator->getCurrentItems(), function (Visit $visit) { + $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) { $rowData = $visit->jsonSerialize(); $rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName(); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 38abbb4d..3f539e27 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Laminas\Paginator\Paginator; use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; -use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -29,7 +29,7 @@ use function sprintf; class ListShortUrlsCommand extends AbstractWithDateRangeCommand { - use PaginatorUtilsTrait; + use PagerfantaUtilsTrait; public const NAME = 'short-url:list'; private const COLUMNS_WHITELIST = [ @@ -132,7 +132,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all); $page++; - $continue = ! $this->isLastPage($result) && $io->confirm( + $continue = $result->hasNextPage() && $io->confirm( sprintf('Continue with page %s?', $page), false, ); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index 9239544e..50c1751f 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; -use Laminas\Paginator\Adapter\ArrayAdapter; -use Laminas\Paginator\Paginator; +use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 918dc39a..aca72e06 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; -use Laminas\Paginator\Adapter\ArrayAdapter; -use Laminas\Paginator\Paginator; +use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -89,7 +89,7 @@ class ListShortUrlsCommandTest extends TestCase { $page = 5; $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page])) - ->willReturn(new Paginator(new ArrayAdapter())) + ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); @@ -100,7 +100,7 @@ class ListShortUrlsCommandTest extends TestCase public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void { $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) - ->willReturn(new Paginator(new ArrayAdapter())) + ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); @@ -127,7 +127,7 @@ class ListShortUrlsCommandTest extends TestCase 'tags' => $tags, 'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null, 'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null, - ]))->willReturn(new Paginator(new ArrayAdapter())); + ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['n']); $this->commandTester->execute($commandArgs); @@ -180,7 +180,7 @@ class ListShortUrlsCommandTest extends TestCase { $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([ 'orderBy' => $expectedOrderBy, - ]))->willReturn(new Paginator(new ArrayAdapter())); + ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['n']); $this->commandTester->execute($commandArgs); @@ -207,7 +207,7 @@ class ListShortUrlsCommandTest extends TestCase 'endDate' => null, 'orderBy' => null, 'itemsPerPage' => -1, - ]))->willReturn(new Paginator(new ArrayAdapter())); + ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute(['--all' => true]); diff --git a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php index cc2a8287..217d5eff 100644 --- a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Laminas\Paginator\Adapter\AdapterInterface; +use Pagerfanta\Adapter\AdapterInterface; abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface { private ?int $count = null; - final public function count(): int + final public function getNbResults(): int { // Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally // cache the count value. diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 93fd88c7..093bd8fd 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; use Happyr\DoctrineSpecification\Specification\Specification; -use Laminas\Paginator\Adapter\AdapterInterface; +use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -23,10 +23,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->apiKey = $apiKey; } - public function getItems($offset, $itemCountPerPage): array // phpcs:ignore + public function getSlice($offset, $length): array // phpcs:ignore { return $this->repository->findList( - $itemCountPerPage, + $length, $offset, $this->params->searchTerm(), $this->params->tags(), @@ -36,7 +36,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface ); } - public function count(): int + public function getNbResults(): int { return $this->repository->countList( $this->params->searchTerm(), diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index 3b73509a..4c4e718b 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -28,12 +28,12 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte $this->apiKey = $apiKey; } - public function getItems($offset, $itemCountPerPage): array // phpcs:ignore + public function getSlice($offset, $length): array // phpcs:ignore { return $this->visitRepository->findVisitsByTag( $this->tag, $this->params->getDateRange(), - $itemCountPerPage, + $length, $offset, $this->resolveSpec(), ); diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index 29498a6d..02ba37b3 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -28,13 +28,13 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter $this->spec = $spec; } - public function getItems($offset, $itemCountPerPage): array // phpcs:ignore + public function getSlice($offset, $length): array // phpcs:ignore { return $this->visitRepository->findVisitsByShortCode( $this->identifier->shortCode(), $this->identifier->domain(), $this->params->getDateRange(), - $itemCountPerPage, + $length, $offset, $this->spec, ); diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 06b39f08..aeb5233b 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; -use Laminas\Paginator\Paginator; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; @@ -45,8 +45,8 @@ class ShortUrlService implements ShortUrlServiceInterface /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey)); - $paginator->setItemCountPerPage($params->itemsPerPage()) - ->setCurrentPageNumber($params->page()); + $paginator->setMaxPerPage($params->itemsPerPage()) + ->setCurrentPage($params->page()); return $paginator; } diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 5f6b9b30..b1cfbc2d 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; -use Laminas\Paginator\Paginator; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index 46d4bd6b..a8362f7c 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; -use Laminas\Paginator\Paginator; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; @@ -66,8 +66,8 @@ class VisitsTracker implements VisitsTrackerInterface /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); - $paginator->setItemCountPerPage($params->getItemsPerPage()) - ->setCurrentPageNumber($params->getPage()); + $paginator->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); return $paginator; } @@ -87,8 +87,8 @@ class VisitsTracker implements VisitsTrackerInterface /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); - $paginator->setItemCountPerPage($params->getItemsPerPage()) - ->setCurrentPageNumber($params->getPage()); + $paginator->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); return $paginator; } diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index ecffae23..0814d986 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; -use Laminas\Paginator\Paginator; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index c3848aa5..93aba122 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -47,7 +47,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce(); - $adapter->getItems(5, 10); + $adapter->getSlice(5, 10); } /** @@ -71,7 +71,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->countList($searchTerm, $tags, $dateRange, $apiKey->spec())->shouldBeCalledOnce(); - $adapter->count(); + $adapter->getNbResults(); } public function provideFilteringArgs(): iterable diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index a0bc6405..8dc88495 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -34,7 +34,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]); for ($i = 0; $i < $count; $i++) { - $adapter->getItems($offset, $limit); + $adapter->getSlice($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -49,7 +49,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $adapter->count(); + $adapter->getNbResults(); } $countVisits->shouldHaveBeenCalledOnce(); diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 76ccc220..436b4b7d 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -37,7 +37,7 @@ class VisitsPaginatorAdapterTest extends TestCase ); for ($i = 0; $i < $count; $i++) { - $adapter->getItems($offset, $limit); + $adapter->getSlice($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -52,7 +52,7 @@ class VisitsPaginatorAdapterTest extends TestCase $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $adapter->count(); + $adapter->getNbResults(); } $countVisits->shouldHaveBeenCalledOnce(); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 99f26a53..5ced9b1a 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -69,8 +69,10 @@ class ShortUrlServiceTest extends TestCase $repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce(); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); - self::assertEquals(4, $list->getCurrentItemCount()); + $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); + + self::assertCount(4, $paginator); + self::assertCount(4, $paginator->getCurrentPageResults()); } /** diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 17135f57..1efe61df 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -83,7 +83,7 @@ class VisitsTrackerTest extends TestCase $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); - self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); $count->shouldHaveBeenCalledOnce(); } @@ -137,7 +137,7 @@ class VisitsTrackerTest extends TestCase $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); - self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 35273dcc..8da502cf 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ListShortUrlsAction extends AbstractRestAction { - use PaginatorUtilsTrait; + use PagerfantaUtilsTrait; protected const ROUTE_PATH = '/short-urls'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 4a9a95e9..7b7c1055 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ShortUrlVisitsAction extends AbstractRestAction { - use PaginatorUtilsTrait; + use PagerfantaUtilsTrait; protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index c83ee95c..aec42ebb 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class TagVisitsAction extends AbstractRestAction { - use PaginatorUtilsTrait; + use PagerfantaUtilsTrait; protected const ROUTE_PATH = '/tags/{tag}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; diff --git a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php index 22864108..a9bdca71 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; +use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; -use function GuzzleHttp\Psr7\build_query; use function sprintf; class ShortUrlVisitsActionTest extends ApiTestCase @@ -52,7 +52,7 @@ class ShortUrlVisitsActionTest extends ApiTestCase $url = new Uri(sprintf('/short-urls/%s/visits', $shortCode)); if ($domain !== null) { - $url = $url->withQuery(build_query(['domain' => $domain])); + $url = $url->withQuery(Query::build(['domain' => $domain])); } $resp = $this->callApiWithKey(self::METHOD_GET, (string) $url); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 7c4d47f7..fd51fa16 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -7,11 +7,11 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; -use Laminas\Paginator\Adapter\ArrayAdapter; -use Laminas\Paginator\Paginator; +use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; @@ -56,7 +56,7 @@ class ListShortUrlsActionTest extends TestCase 'orderBy' => $expectedOrderBy, 'startDate' => $startDate, 'endDate' => $endDate, - ]), $apiKey)->willReturn(new Paginator(new ArrayAdapter())); + ]), $apiKey)->willReturn(new Paginator(new ArrayAdapter([]))); /** @var JsonResponse $response */ $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 0bedbd37..9c751214 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -6,13 +6,13 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Cake\Chronos\Chronos; use Laminas\Diactoros\ServerRequestFactory; -use Laminas\Paginator\Adapter\ArrayAdapter; -use Laminas\Paginator\Paginator; +use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index a7598971..c9097d07 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\ServerRequest; -use Laminas\Paginator\Adapter\ArrayAdapter; -use Laminas\Paginator\Paginator; +use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; From 83a29d6ed0b5c834a62f54416af1f5cefe04d2b7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Jan 2021 14:38:58 +0100 Subject: [PATCH 006/115] Updated changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d940e7d..84af470c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [2.5.1] - 2021-01-21 ### Added * *Nothing* From 3b1fc2a27d28293148aa0ce838ff07661dc05d8d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Jan 2021 22:56:43 +0100 Subject: [PATCH 007/115] Updated link to PHPUnit's xsd to use local one --- phpunit-api.xml | 2 +- phpunit-db.xml | 2 +- phpunit.xml.dist | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phpunit-api.xml b/phpunit-api.xml index b38a3c0f..38a53ca4 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -1,7 +1,7 @@ diff --git a/phpunit-db.xml b/phpunit-db.xml index 030f777b..b2dd8008 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -1,7 +1,7 @@ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9c8e02df..29c60b6b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ From db997fe6f57a75e972e8f6a37c30aed81b9e29f7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Jan 2021 22:59:19 +0100 Subject: [PATCH 008/115] Do not allow ignoring platform reqs anymore during CI --- .github/workflows/ci.yml | 40 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de1064d8..abeeccdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,10 +60,7 @@ jobs: extensions: swoole-4.5.9 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: composer test:unit:ci - uses: actions/upload-artifact@v2 if: ${{ matrix.php-version == '7.4' }} @@ -89,10 +86,7 @@ jobs: extensions: swoole-4.5.9 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: composer test:db:sqlite:ci - uses: actions/upload-artifact@v2 if: ${{ matrix.php-version == '7.4' }} @@ -119,10 +113,7 @@ jobs: tools: composer extensions: swoole-4.5.9 coverage: none - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: composer test:db:mysql db-tests-maria: @@ -142,10 +133,7 @@ jobs: tools: composer extensions: swoole-4.5.9 coverage: none - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: composer test:db:maria db-tests-postgres: @@ -165,10 +153,7 @@ jobs: tools: composer extensions: swoole-4.5.9 coverage: none - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: composer test:db:postgres db-tests-ms: @@ -190,10 +175,7 @@ jobs: tools: composer extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2 coverage: none - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - name: Create test database run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - run: composer test:db:ms @@ -217,10 +199,7 @@ jobs: extensions: swoole-4.5.9 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - run: bin/test/run-api-tests.sh - uses: actions/upload-artifact@v2 if: ${{ matrix.php-version == '7.4' }} @@ -250,10 +229,7 @@ jobs: extensions: swoole-4.5.9 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.0' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.0' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - uses: actions/download-artifact@v2 with: path: build From 17eb6dc4ced289109033188490421dfb4fbd0f91 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Jan 2021 23:00:10 +0100 Subject: [PATCH 009/115] Updated remaining dependencies without PHP 8 support --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index af175a90..0c397471 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^7.4", + "php": "^7.4 || ^8.0", "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.0", @@ -34,10 +34,10 @@ "lcobucci/jwt": "^4.0", "league/uri": "^6.2", "lstrojny/functional-php": "^1.15", - "mezzio/mezzio": "^3.2", + "mezzio/mezzio": "^3.3", "mezzio/mezzio-fastroute": "^3.1", "mezzio/mezzio-helpers": "^5.3", - "mezzio/mezzio-problem-details": "^1.1", + "mezzio/mezzio-problem-details": "^1.3", "mezzio/mezzio-swoole": "^3.1", "monolog/monolog": "^2.0", "nikolaposa/monolog-factory": "^3.1", From 741e8f625c921749a0ff243896868773b864fd83 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Jan 2021 23:09:46 +0100 Subject: [PATCH 010/115] No longer allow errors on any step during CI --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abeeccdf..b5329f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,7 +182,6 @@ jobs: api-tests: runs-on: ubuntu-20.04 - continue-on-error: ${{ matrix.php-version == '8.0' }} strategy: matrix: php-version: ['7.4', '8.0'] From eb268fb8563b09a90bba6f458f16090c5713afb1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Jan 2021 23:26:28 +0100 Subject: [PATCH 011/115] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb2a4c9..0481274b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. From 4c008f16724215b186b69220e51654da46a51ee7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 09:31:08 +0100 Subject: [PATCH 012/115] Updated dev docker images to PHP 8 --- .github/workflows/ci.yml | 20 +++++------ .github/workflows/publish-release.yml | 2 +- data/infra/php.Dockerfile | 36 ++++++------------- data/infra/swoole.Dockerfile | 52 ++++++++++----------------- 4 files changed, 40 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5329f76..247bf0d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer cs @@ -39,7 +39,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer stan @@ -57,7 +57,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -83,7 +83,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -111,7 +111,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:mysql @@ -131,7 +131,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:maria @@ -151,7 +151,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:postgres @@ -173,7 +173,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2 + extensions: swoole-4.6.2, pdo_sqlsrv-5.9.0 coverage: none - run: composer install --no-interaction --prefer-dist - name: Create test database @@ -195,7 +195,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -225,7 +225,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c1009f1c..565311ed 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: with: php-version: '7.4' # Publish release with lowest supported PHP version tools: composer - extensions: swoole-4.5.9 + extensions: swoole-4.6.2 - name: Generate release assets run: ./build.sh ${GITHUB_REF#refs/tags/v} - name: Publish release with assets diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 884f2f2e..671224b8 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,8 @@ -FROM php:7.4.11-fpm-alpine3.12 +FROM php:8.0.1-fpm-alpine3.13 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.18 -ENV APCU_BC_VERSION 1.0.5 +ENV APCU_VERSION 5.1.19 +ENV PDO_SQLSRV_VERSION 5.9.0 RUN apk update @@ -35,33 +35,19 @@ RUN docker-php-ext-install gmp # Install APCu extension ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu\ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 -# configure and install -RUN docker-php-ext-configure apcu\ - && docker-php-ext-install apcu -# cleanup -RUN rm /tmp/apcu.tar.gz - -# Install APCu-BC extension -ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu-bc\ - && tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1 -# configure and install -RUN docker-php-ext-configure apcu-bc\ - && docker-php-ext-install apcu-bc -# cleanup -RUN rm /tmp/apcu_bc.tar.gz - -# Load APCU.ini before APC.ini -RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini -RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini +RUN mkdir -p /usr/src/php/ext/apcu \ + && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ + && docker-php-ext-configure apcu \ + && docker-php-ext-install apcu \ + && rm /tmp/apcu.tar.gz \ + && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ + && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini # Install pcov and sqlsrv driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install pdo_sqlsrv pcov && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ docker-php-ext-enable pdo_sqlsrv pcov && \ apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index bb1f084c..58f9a5fa 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:7.4.11-alpine3.12 +FROM php:8.0.1-alpine3.13 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.18 -ENV APCU_BC_VERSION 1.0.5 -ENV INOTIFY_VERSION 2.0.0 -ENV SWOOLE_VERSION 4.5.9 +ENV APCU_VERSION 5.1.19 +ENV PDO_SQLSRV_VERSION 5.9.0 +ENV INOTIFY_VERSION 3.0.0 +ENV SWOOLE_VERSION 4.6.2 RUN apk update @@ -37,43 +37,27 @@ RUN docker-php-ext-install gmp # Install APCu extension ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu\ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 -# configure and install -RUN docker-php-ext-configure apcu\ - && docker-php-ext-install apcu -# cleanup -RUN rm /tmp/apcu.tar.gz - -# Install APCu-BC extension -ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu-bc\ - && tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1 -# configure and install -RUN docker-php-ext-configure apcu-bc\ - && docker-php-ext-install apcu-bc -# cleanup -RUN rm /tmp/apcu_bc.tar.gz - -# Load APCU.ini before APC.ini -RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini -RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini +RUN mkdir -p /usr/src/php/ext/apcu \ + && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ + && docker-php-ext-configure apcu \ + && docker-php-ext-install apcu \ + && rm /tmp/apcu.tar.gz \ + && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ + && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini # Install inotify extension ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz -RUN mkdir -p /usr/src/php/ext/inotify\ - && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 -# configure and install -RUN docker-php-ext-configure inotify\ - && docker-php-ext-install inotify -# cleanup -RUN rm /tmp/inotify.tar.gz +RUN mkdir -p /usr/src/php/ext/inotify \ + && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \ + && docker-php-ext-configure inotify \ + && docker-php-ext-install inotify \ + && rm /tmp/inotify.tar.gz # Install swoole, pcov and mssql driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \ + pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ docker-php-ext-enable swoole pdo_sqlsrv pcov && \ apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk From 755a52b78e8cf838e56c1163a2bfc36ef0e8dd69 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 09:45:47 +0100 Subject: [PATCH 013/115] Updated official docker image to PHP 8 --- CHANGELOG.md | 1 + Dockerfile | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0481274b..86dacaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. +* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. ### Deprecated * *Nothing* diff --git a/Dockerfile b/Dockerfile index 9d7e0bef..8d17f662 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM php:7.4.11-alpine3.12 as base +FROM php:8.0.1-alpine3.13 as base -ARG SHLINK_VERSION=2.4.0 -ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.5.9 +ARG SHLINK_VERSION=2.5.2 +ENV SWOOLE_VERSION 4.6.2 +ENV PDO_SQLSRV_VERSION 5.9.0 ENV LC_ALL "C" WORKDIR /etc/shlink @@ -32,7 +32,7 @@ RUN if [ $(uname -m) == "x86_64" ]; then \ wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ - pecl install pdo_sqlsrv && \ + pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk ; \ From bc38ecf6def67abfeaac4eebe21b2168af50113b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 09:54:47 +0100 Subject: [PATCH 014/115] Fixed image which checks if Dockerfile changed by making sure it fetches more commits --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 247bf0d2..b591afbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,6 +283,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + fetch-depth: 100 - uses: marceloprado/has-changed-path@v1 id: changed-dockerfile with: From 0d59ebfe5549fe782f0aef23893495f0651bf453 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 10:08:33 +0100 Subject: [PATCH 015/115] Recovered ARG to ENV in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 8d17f662..d215d1f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM php:8.0.1-alpine3.13 as base ARG SHLINK_VERSION=2.5.2 +ENV SHLINK_VERSION ${SHLINK_VERSION} ENV SWOOLE_VERSION 4.6.2 ENV PDO_SQLSRV_VERSION 5.9.0 ENV LC_ALL "C" From 96d07c4b4e8370da740705fc92bd5227ed085143 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 10:54:04 +0100 Subject: [PATCH 016/115] Deprecated camelCase options in some CLI commands --- .../src/Command/Api/GenerateKeyCommand.php | 14 ++--- .../CLI/src/Command/Api/ListKeysCommand.php | 10 ++-- module/CLI/src/Command/BaseCommand.php | 51 ++++++++++++++++++ .../ShortUrl/GenerateShortUrlCommand.php | 54 ++++++++++--------- module/Core/functions/functions.php | 8 +++ 5 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 module/CLI/src/Command/BaseCommand.php diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 2dc91c51..119fa020 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; +use Shlinkio\Shlink\CLI\Command\BaseCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use function Shlinkio\Shlink\Core\arrayToString; use function sprintf; -class GenerateKeyCommand extends Command +class GenerateKeyCommand extends BaseCommand { public const NAME = 'api-key:generate'; @@ -42,9 +42,9 @@ class GenerateKeyCommand extends Command %command.full_name% - You can optionally set its expiration date with --expirationDate or -e: + You can optionally set its expiration date with --expiration-date or -e: - %command.full_name% --expirationDate 2020-01-01 + %command.full_name% --expiration-date 2020-01-01 You can also set roles to the API key: @@ -56,8 +56,8 @@ class GenerateKeyCommand extends Command $this ->setName(self::NAME) ->setDescription('Generates a new valid API key.') - ->addOption( - 'expirationDate', + ->addOptionWithDeprecatedFallback( + 'expiration-date', 'e', InputOption::VALUE_REQUIRED, 'The date in which the API key should expire. Use any valid PHP format.', @@ -79,7 +79,7 @@ class GenerateKeyCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - $expirationDate = $input->getOption('expirationDate'); + $expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date'); $apiKey = $this->apiKeyService->create( isset($expirationDate) ? Chronos::parse($expirationDate) : null, ...$this->roleResolver->determineRoles($input), diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index cf09e614..9243779b 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; +use Shlinkio\Shlink\CLI\Command\BaseCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -19,7 +19,7 @@ use function Functional\map; use function implode; use function sprintf; -class ListKeysCommand extends Command +class ListKeysCommand extends BaseCommand { private const ERROR_STRING_PATTERN = '%s'; private const SUCCESS_STRING_PATTERN = '%s'; @@ -40,8 +40,8 @@ class ListKeysCommand extends Command $this ->setName(self::NAME) ->setDescription('Lists all the available API keys.') - ->addOption( - 'enabledOnly', + ->addOptionWithDeprecatedFallback( + 'enabled-only', 'e', InputOption::VALUE_NONE, 'Tells if only enabled API keys should be returned.', @@ -50,7 +50,7 @@ class ListKeysCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - $enabledOnly = $input->getOption('enabledOnly'); + $enabledOnly = $this->getOptionWithDeprecatedFallback($input, 'enabled-only'); $rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) { $expiration = $apiKey->getExpirationDate(); diff --git a/module/CLI/src/Command/BaseCommand.php b/module/CLI/src/Command/BaseCommand.php new file mode 100644 index 00000000..443b37ec --- /dev/null +++ b/module/CLI/src/Command/BaseCommand.php @@ -0,0 +1,51 @@ +addOption($name, $shortcut, $mode, $description, $default); + + if (str_contains($name, '-')) { + $camelCaseName = kebabCaseToCamelCase($name); + $this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default); + } + + return $this; + } + + /** + * @return bool|string|string[]|null + */ + protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) + { + $rawInput = method_exists($input, '__toString') ? $input->__toString() : ''; + $camelCaseName = kebabCaseToCamelCase($name); + + if (str_contains($rawInput, $camelCaseName)) { + return $input->getOption($camelCaseName); + } + + return $input->getOption($name); + } +} diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 12bbb3fb..7ceb0435 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Command\BaseCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -23,9 +23,9 @@ use function Functional\flatten; use function Functional\unique; use function method_exists; use function sprintf; -use function strpos; +use function str_contains; -class GenerateShortUrlCommand extends Command +class GenerateShortUrlCommand extends BaseCommand { public const NAME = 'short-url:generate'; @@ -53,34 +53,34 @@ class GenerateShortUrlCommand extends Command InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Tags to apply to the new short URL', ) - ->addOption( - 'validSince', + ->addOptionWithDeprecatedFallback( + 'valid-since', 's', InputOption::VALUE_REQUIRED, 'The date from which this short URL will be valid. ' . 'If someone tries to access it before this date, it will not be found.', ) - ->addOption( - 'validUntil', + ->addOptionWithDeprecatedFallback( + 'valid-until', 'u', InputOption::VALUE_REQUIRED, 'The date until which this short URL will be valid. ' . 'If someone tries to access it after this date, it will not be found.', ) - ->addOption( - 'customSlug', + ->addOptionWithDeprecatedFallback( + 'custom-slug', 'c', InputOption::VALUE_REQUIRED, 'If provided, this slug will be used instead of generating a short code', ) - ->addOption( - 'maxVisits', + ->addOptionWithDeprecatedFallback( + 'max-visits', 'm', InputOption::VALUE_REQUIRED, 'This will limit the number of visits for this short URL.', ) - ->addOption( - 'findIfExists', + ->addOptionWithDeprecatedFallback( + 'find-if-exists', 'f', InputOption::VALUE_NONE, 'This will force existing matching URL to be returned if found, instead of creating a new one.', @@ -91,11 +91,11 @@ class GenerateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'The domain to which this short URL will be attached.', ) - ->addOption( - 'shortCodeLength', + ->addOptionWithDeprecatedFallback( + 'short-code-length', 'l', InputOption::VALUE_REQUIRED, - 'The length for generated short code (it will be ignored if --customSlug was provided).', + 'The length for generated short code (it will be ignored if --custom-slug was provided).', ) ->addOption( 'validate-url', @@ -136,18 +136,24 @@ class GenerateShortUrlCommand extends Command $explodeWithComma = curry('explode')(','); $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); - $customSlug = $input->getOption('customSlug'); - $maxVisits = $input->getOption('maxVisits'); - $shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength; + $customSlug = $this->getOptionWithDeprecatedFallback($input, 'custom-slug'); + $maxVisits = $this->getOptionWithDeprecatedFallback($input, 'max-visits'); + $shortCodeLength = $this->getOptionWithDeprecatedFallback( + $input, + 'short-code-length', + ) ?? $this->defaultShortCodeLength; $doValidateUrl = $this->doValidateUrl($input); try { $shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'), - ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'), + ShortUrlMetaInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'), + ShortUrlMetaInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'), ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug, ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, - ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'), + ShortUrlMetaInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback( + $input, + 'find-if-exists', + ), ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl, @@ -168,10 +174,10 @@ class GenerateShortUrlCommand extends Command { $rawInput = method_exists($input, '__toString') ? $input->__toString() : ''; - if (strpos($rawInput, '--no-validate-url') !== false) { + if (str_contains($rawInput, '--no-validate-url')) { return false; } - if (strpos($rawInput, '--validate-url') !== false) { + if (str_contains($rawInput, '--validate-url')) { return true; } diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 076de6a0..531f8038 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -12,9 +12,12 @@ use PUGX\Shortid\Factory as ShortIdFactory; use function Functional\reduce_left; use function is_array; +use function lcfirst; use function print_r; use function sprintf; use function str_repeat; +use function str_replace; +use function ucwords; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; @@ -97,3 +100,8 @@ function arrayToString(array $array, int $indentSize = 4): string ); }, ''); } + +function kebabCaseToCamelCase(string $name): string +{ + return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $name)))); +} From 158e981970d6dbe2ffdaa85aecdb6ee05d5ba923 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 11:17:13 +0100 Subject: [PATCH 017/115] Deprecated camelCase options in rest of CLI commands --- .../src/Command/ShortUrl/GetVisitsCommand.php | 17 ++++---- .../Command/ShortUrl/ListShortUrlsCommand.php | 43 +++++++++++-------- .../Util/AbstractWithDateRangeCommand.php | 40 +++++++++++++---- .../Command/ShortUrl/GetVisitsCommandTest.php | 2 +- 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index b58ea3ac..0b7de663 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use function Functional\map; use function Functional\select_keys; +use function sprintf; class GetVisitsCommand extends AbstractWithDateRangeCommand { @@ -39,18 +40,18 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand $this ->setName(self::NAME) ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code'); + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.') + ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); } - protected function getStartDateDesc(): string + protected function getStartDateDesc(string $optionName): string { - return 'Allows to filter visits, returning only those older than start date'; + return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName); } - protected function getEndDateDesc(): string + protected function getEndDateDesc(string $optionName): string { - return 'Allows to filter visits, returning only those newer than end date'; + return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName); } protected function interact(InputInterface $input, OutputInterface $output): void @@ -70,8 +71,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand protected function execute(InputInterface $input, OutputInterface $output): ?int { $identifier = ShortUrlIdentifier::fromCli($input); - $startDate = $this->getDateOption($input, $output, 'startDate'); - $endDate = $this->getDateOption($input, $output, 'endDate'); + $startDate = $this->getStartDateOption($input, $output); + $endDate = $this->getEndDateOption($input, $output); $paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate))); diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 3f539e27..cf20e328 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -60,28 +60,33 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand 'page', 'p', InputOption::VALUE_REQUIRED, - 'The first page to list (10 items per page unless "--all" is provided)', + 'The first page to list (10 items per page unless "--all" is provided).', '1', ) - ->addOption( - 'searchTerm', + ->addOptionWithDeprecatedFallback( + 'search-term', 'st', InputOption::VALUE_REQUIRED, - 'A query used to filter results by searching for it on the longUrl and shortCode fields', + 'A query used to filter results by searching for it on the longUrl and shortCode fields.', ) ->addOption( 'tags', 't', InputOption::VALUE_REQUIRED, - 'A comma-separated list of tags to filter results', + 'A comma-separated list of tags to filter results.', ) - ->addOption( - 'orderBy', + ->addOptionWithDeprecatedFallback( + 'order-by', 'o', InputOption::VALUE_REQUIRED, - 'The field from which we want to order by. Pass ASC or DESC separated by a comma', + 'The field from which we want to order by. Pass ASC or DESC separated by a comma.', + ) + ->addOptionWithDeprecatedFallback( + 'show-tags', + null, + InputOption::VALUE_NONE, + 'Whether to display the tags or not.', ) - ->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not') ->addOption( 'all', 'a', @@ -91,14 +96,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ); } - protected function getStartDateDesc(): string + protected function getStartDateDesc(string $optionName): string { - return 'Allows to filter short URLs, returning only those created after "startDate"'; + return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName); } - protected function getEndDateDesc(): string + protected function getEndDateDesc(string $optionName): string { - return 'Allows to filter short URLs, returning only those created before "endDate"'; + return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName); } protected function execute(InputInterface $input, OutputInterface $output): ?int @@ -106,13 +111,13 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $io = new SymfonyStyle($input, $output); $page = (int) $input->getOption('page'); - $searchTerm = $input->getOption('searchTerm'); + $searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term'); $tags = $input->getOption('tags'); $tags = ! empty($tags) ? explode(',', $tags) : []; - $showTags = (bool) $input->getOption('showTags'); - $all = (bool) $input->getOption('all'); - $startDate = $this->getDateOption($input, $output, 'startDate'); - $endDate = $this->getDateOption($input, $output, 'endDate'); + $showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags'); + $all = $input->getOption('all'); + $startDate = $this->getStartDateOption($input, $output); + $endDate = $this->getEndDateOption($input, $output); $orderBy = $this->processOrderBy($input); $data = [ @@ -178,7 +183,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand */ private function processOrderBy(InputInterface $input) { - $orderBy = $input->getOption('orderBy'); + $orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by'); if (empty($orderBy)) { return null; } diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php index bd64701a..39e60c9a 100644 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Util; use Cake\Chronos\Chronos; -use Symfony\Component\Console\Command\Command; +use Shlinkio\Shlink\CLI\Command\BaseCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -13,19 +13,42 @@ use Throwable; use function sprintf; -abstract class AbstractWithDateRangeCommand extends Command +abstract class AbstractWithDateRangeCommand extends BaseCommand { + private const START_DATE = 'start-date'; + private const END_DATE = 'end-date'; + final protected function configure(): void { $this->doConfigure(); $this - ->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc()) - ->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc()); + ->addOptionWithDeprecatedFallback( + self::START_DATE, + 's', + InputOption::VALUE_REQUIRED, + $this->getStartDateDesc(self::START_DATE), + ) + ->addOptionWithDeprecatedFallback( + self::END_DATE, + 'e', + InputOption::VALUE_REQUIRED, + $this->getEndDateDesc(self::END_DATE), + ); } - protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos + protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos { - $value = $input->getOption($key); + return $this->getDateOption($input, $output, self::START_DATE); + } + + protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos + { + return $this->getDateOption($input, $output, self::END_DATE); + } + + private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos + { + $value = $this->getOptionWithDeprecatedFallback($input, $key); if (empty($value)) { return null; } @@ -49,6 +72,7 @@ abstract class AbstractWithDateRangeCommand extends Command abstract protected function doConfigure(): void; - abstract protected function getStartDateDesc(): string; - abstract protected function getEndDateDesc(): string; + abstract protected function getStartDateDesc(string $optionName): string; + + abstract protected function getEndDateDesc(string $optionName): string; } diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index 50c1751f..a4239bd2 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -92,7 +92,7 @@ class GetVisitsCommandTest extends TestCase $info->shouldHaveBeenCalledOnce(); self::assertStringContainsString( - sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate), + sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate), $output, ); } From 248d5e2fe513b94697dabf6a0bedb4e0636d403e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 11:19:21 +0100 Subject: [PATCH 018/115] Updated changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86dacaa0..baf1f824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. ### Deprecated -* *Nothing* +* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`). + + All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0 ### Removed * *Nothing* From 752ded2f80708e29a2f6dd617699173a6b034b13 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 11:25:20 +0100 Subject: [PATCH 019/115] Changed to kebab-case for CLI flags in command tests --- .../Command/Api/GenerateKeyCommandTest.php | 2 +- .../test/Command/Api/ListKeysCommandTest.php | 2 +- .../ShortUrl/GenerateShortUrlCommandTest.php | 4 ++-- .../Command/ShortUrl/GetVisitsCommandTest.php | 6 +++--- .../ShortUrl/ListShortUrlsCommandTest.php | 18 +++++++++--------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 744fb482..00548f17 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -55,7 +55,7 @@ class GenerateKeyCommandTest extends TestCase $this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce() ->willReturn(new ApiKey()); $this->commandTester->execute([ - '--expirationDate' => '2016-01-01', + '--expiration-date' => '2016-01-01', ]); } } diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 116f979d..e0cada5d 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -39,7 +39,7 @@ class ListKeysCommandTest extends TestCase { $listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys); - $this->commandTester->execute(['--enabledOnly' => $enabledOnly]); + $this->commandTester->execute(['--enabled-only' => $enabledOnly]); $output = $this->commandTester->getDisplay(); self::assertEquals($expected, $output); diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 82f38713..3283dced 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -48,7 +48,7 @@ class GenerateShortUrlCommandTest extends TestCase $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', - '--maxVisits' => '3', + '--max-visits' => '3', ]); $output = $this->commandTester->getDisplay(); @@ -78,7 +78,7 @@ class GenerateShortUrlCommandTest extends TestCase NonUniqueSlugException::fromSlug('my-slug'), ); - $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']); + $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); $output = $this->commandTester->getDisplay(); self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index a4239bd2..51394414 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -71,8 +71,8 @@ class GetVisitsCommandTest extends TestCase $this->commandTester->execute([ 'shortCode' => $shortCode, - '--startDate' => $startDate, - '--endDate' => $endDate, + '--start-date' => $startDate, + '--end-date' => $endDate, ]); } @@ -86,7 +86,7 @@ class GetVisitsCommandTest extends TestCase $this->commandTester->execute([ 'shortCode' => $shortCode, - '--startDate' => $startDate, + '--start-date' => $startDate, ]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index aca72e06..43047bbf 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -104,7 +104,7 @@ class ListShortUrlsCommandTest extends TestCase ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); - $this->commandTester->execute(['--showTags' => true]); + $this->commandTester->execute(['--show-tags' => true]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Tags', $output); } @@ -139,22 +139,22 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], 1, null, []]; yield [['--page' => $page = 3], $page, null, []]; - yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []]; + yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []]; yield [ - ['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], + ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], $page, $searchTerm, explode(',', $tags), ]; yield [ - ['--startDate' => $startDate = '2019-01-01'], + ['--start-date' => $startDate = '2019-01-01'], 1, null, [], $startDate, ]; yield [ - ['--endDate' => $endDate = '2020-05-23'], + ['--end-date' => $endDate = '2020-05-23'], 1, null, [], @@ -162,7 +162,7 @@ class ListShortUrlsCommandTest extends TestCase $endDate, ]; yield [ - ['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'], + ['--start-date' => $startDate = '2019-01-01', '--end-date' => $endDate = '2020-05-23'], 1, null, [], @@ -191,9 +191,9 @@ class ListShortUrlsCommandTest extends TestCase public function provideOrderBy(): iterable { yield [[], null]; - yield [['--orderBy' => 'foo'], 'foo']; - yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']]; - yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']]; + yield [['--order-by' => 'foo'], 'foo']; + yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']]; + yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']]; } /** @test */ From 07b12fac3c8e1733626b84b393c8c2454dbb7b3d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 14:18:44 +0100 Subject: [PATCH 020/115] Refactored short URL creation so that the long URL is part of the ShortUrlMeta --- .../ShortUrl/GenerateShortUrlCommand.php | 3 +- .../ShortUrl/GenerateShortUrlCommandTest.php | 8 +- .../Command/ShortUrl/GetVisitsCommandTest.php | 2 +- .../ShortUrl/ListShortUrlsCommandTest.php | 4 +- .../ShortUrl/ResolveUrlCommandTest.php | 2 +- .../Command/Visit/LocateVisitsCommandTest.php | 6 +- module/Core/src/Entity/ShortUrl.php | 48 ++++--- module/Core/src/Model/CreateShortUrlData.php | 9 +- module/Core/src/Model/ShortUrlMeta.php | 14 +- .../src/Repository/ShortUrlRepository.php | 4 +- .../ShortUrlRepositoryInterface.php | 2 +- module/Core/src/Service/UrlShortener.php | 14 +- .../src/Service/UrlShortenerInterface.php | 2 +- .../Validation/ShortUrlMetaInputFilter.php | 15 ++- .../Repository/DomainRepositoryTest.php | 5 +- .../Repository/ShortUrlRepositoryTest.php | 120 +++++++++++------- .../test-db/Repository/TagRepositoryTest.php | 11 +- .../Repository/VisitRepositoryTest.php | 19 ++- module/Core/test/Action/PixelActionTest.php | 2 +- module/Core/test/Action/QrCodeActionTest.php | 10 +- .../Core/test/Action/RedirectActionTest.php | 4 +- module/Core/test/Entity/ShortUrlTest.php | 10 +- module/Core/test/Entity/VisitTest.php | 4 +- .../LocateShortUrlVisitTest.php | 10 +- .../NotifyVisitToMercureTest.php | 4 +- .../NotifyVisitToWebHooksTest.php | 4 +- .../Mercure/MercureUpdatesGeneratorTest.php | 2 +- module/Core/test/Model/ShortUrlMetaTest.php | 11 +- .../ShortUrl/DeleteShortUrlServiceTest.php | 4 +- .../Service/ShortUrl/ShortUrlResolverTest.php | 21 +-- .../Core/test/Service/ShortUrlServiceTest.php | 10 +- module/Core/test/Service/UrlShortenerTest.php | 71 +++++------ .../Core/test/Service/VisitsTrackerTest.php | 6 +- .../ShortUrlDataTransformerTest.php | 19 ++- module/Core/test/Visit/VisitLocatorTest.php | 4 +- .../ShortUrl/AbstractCreateShortUrlAction.php | 3 +- .../Action/ShortUrl/CreateShortUrlAction.php | 8 +- .../SingleStepCreateShortUrlAction.php | 12 +- .../test-api/Action/CreateShortUrlTest.php | 27 +++- .../test-api/Fixtures/ShortUrlsFixture.php | 46 +++---- .../ShortUrl/CreateShortUrlActionTest.php | 42 ++---- .../ShortUrl/EditShortUrlActionTest.php | 2 +- .../ShortUrl/EditShortUrlTagsActionTest.php | 2 +- .../ShortUrl/ResolveShortUrlActionTest.php | 2 +- .../SingleStepCreateShortUrlActionTest.php | 21 +-- 45 files changed, 343 insertions(+), 306 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 7ceb0435..6fd82c1a 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -145,7 +145,8 @@ class GenerateShortUrlCommand extends BaseCommand $doValidateUrl = $this->doValidateUrl($input); try { - $shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([ + $shortUrl = $this->urlShortener->shorten($tags, ShortUrlMeta::fromRawData([ + ShortUrlMetaInputFilter::LONG_URL => $longUrl, ShortUrlMetaInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'), ShortUrlMetaInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'), ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug, diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 3283dced..48d9421b 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -43,7 +43,7 @@ class GenerateShortUrlCommandTest extends TestCase /** @test */ public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); $this->commandTester->execute([ @@ -89,9 +89,8 @@ class GenerateShortUrlCommandTest extends TestCase /** @test */ public function properlyProcessesProvidedTags(): void { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten( - Argument::type('string'), Argument::that(function (array $tags) { Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); return $tags; @@ -116,9 +115,8 @@ class GenerateShortUrlCommandTest extends TestCase */ public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten( - Argument::type('string'), Argument::type('array'), Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) { Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index 51394414..3da492e3 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -103,7 +103,7 @@ class GetVisitsCommandTest extends TestCase $shortCode = 'abc123'; $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( new Paginator(new ArrayAdapter([ - (new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate( + (new Visit(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '')))->locate( new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')), ), ])), diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 43047bbf..784391e0 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -42,7 +42,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 50; $i++) { - $data[] = new ShortUrl('url_' . $i); + $data[] = ShortUrl::withLongUrl('url_' . $i); } $this->shortUrlService->listShortUrls(Argument::cetera()) @@ -64,7 +64,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 30; $i++) { - $data[] = new ShortUrl('url_' . $i); + $data[] = ShortUrl::withLongUrl('url_' . $i); } $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index a84a1ee3..f0025b65 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -41,7 +41,7 @@ class ResolveUrlCommandTest extends TestCase { $shortCode = 'abc123'; $expectedUrl = 'http://domain.com/foo/bar'; - $shortUrl = new ShortUrl($expectedUrl); + $shortUrl = ShortUrl::withLongUrl($expectedUrl); $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl) ->shouldBeCalledOnce(); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index bb9f4715..fc64d643 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -77,7 +77,7 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args ): void { - $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4')); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')); $location = new VisitLocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); @@ -121,7 +121,7 @@ class LocateVisitsCommandTest extends TestCase */ public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void { - $visit = new Visit(new ShortUrl(''), new Visitor('', '', $address)); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $address)); $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( @@ -154,7 +154,7 @@ class LocateVisitsCommandTest extends TestCase /** @test */ public function errorWhileLocatingIpIsDisplayed(): void { - $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4')); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')); $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 67d41136..3c076585 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -40,26 +40,37 @@ class ShortUrl extends AbstractEntity private ?string $importOriginalShortCode = null; private ?ApiKey $authorApiKey = null; - public function __construct( - string $longUrl, - ?ShortUrlMeta $meta = null, + public static function createEmpty(): self + { + return self::fromMeta(ShortUrlMeta::createEmpty()); + } + + public static function withLongUrl(string $longUrl): self + { + return self::fromMeta(ShortUrlMeta::fromRawData([ShortUrlMetaInputFilter::LONG_URL => $longUrl])); + } + + public static function fromMeta( + ShortUrlMeta $meta, ?ShortUrlRelationResolverInterface $relationResolver = null - ) { - $meta = $meta ?? ShortUrlMeta::createEmpty(); + ): self { + $instance = new self(); $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); - $this->longUrl = $longUrl; - $this->dateCreated = Chronos::now(); - $this->visits = new ArrayCollection(); - $this->tags = new ArrayCollection(); - $this->validSince = $meta->getValidSince(); - $this->validUntil = $meta->getValidUntil(); - $this->maxVisits = $meta->getMaxVisits(); - $this->customSlugWasProvided = $meta->hasCustomSlug(); - $this->shortCodeLength = $meta->getShortCodeLength(); - $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength); - $this->domain = $relationResolver->resolveDomain($meta->getDomain()); - $this->authorApiKey = $meta->getApiKey(); + $instance->longUrl = $meta->getLongUrl(); + $instance->dateCreated = Chronos::now(); + $instance->visits = new ArrayCollection(); + $instance->tags = new ArrayCollection(); + $instance->validSince = $meta->getValidSince(); + $instance->validUntil = $meta->getValidUntil(); + $instance->maxVisits = $meta->getMaxVisits(); + $instance->customSlugWasProvided = $meta->hasCustomSlug(); + $instance->shortCodeLength = $meta->getShortCodeLength(); + $instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); + $instance->domain = $relationResolver->resolveDomain($meta->getDomain()); + $instance->authorApiKey = $meta->getApiKey(); + + return $instance; } public static function fromImport( @@ -68,6 +79,7 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null ): self { $meta = [ + ShortUrlMetaInputFilter::LONG_URL => $url->longUrl(), ShortUrlMetaInputFilter::DOMAIN => $url->domain(), ShortUrlMetaInputFilter::VALIDATE_URL => false, ]; @@ -75,7 +87,7 @@ class ShortUrl extends AbstractEntity $meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode(); } - $instance = new self($url->longUrl(), ShortUrlMeta::fromRawData($meta), $relationResolver); + $instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver); $instance->importSource = $url->source(); $instance->importOriginalShortCode = $url->shortCode(); $instance->dateCreated = Chronos::instance($url->createdAt()); diff --git a/module/Core/src/Model/CreateShortUrlData.php b/module/Core/src/Model/CreateShortUrlData.php index 9b64302d..d944ac42 100644 --- a/module/Core/src/Model/CreateShortUrlData.php +++ b/module/Core/src/Model/CreateShortUrlData.php @@ -6,22 +6,15 @@ namespace Shlinkio\Shlink\Core\Model; final class CreateShortUrlData { - private string $longUrl; private array $tags; private ShortUrlMeta $meta; - public function __construct(string $longUrl, array $tags = [], ?ShortUrlMeta $meta = null) + public function __construct(array $tags = [], ?ShortUrlMeta $meta = null) { - $this->longUrl = $longUrl; $this->tags = $tags; $this->meta = $meta ?? ShortUrlMeta::createEmpty(); } - public function getLongUrl(): string - { - return $this->longUrl; - } - /** * @return string[] */ diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 0df792be..9291a5e6 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -17,6 +17,7 @@ use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; final class ShortUrlMeta { + private string $longUrl; private ?Chronos $validSince = null; private ?Chronos $validUntil = null; private ?string $customSlug = null; @@ -34,7 +35,10 @@ final class ShortUrlMeta public static function createEmpty(): self { - return new self(); + $meta = new self(); + $meta->longUrl = ''; + + return $meta; } /** @@ -52,11 +56,12 @@ final class ShortUrlMeta */ private function validateAndInit(array $data): void { - $inputFilter = new ShortUrlMetaInputFilter($data); + $inputFilter = new ShortUrlMetaInputFilter($data, true); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } + $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL); $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); @@ -71,6 +76,11 @@ final class ShortUrlMeta $this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY); } + public function getLongUrl(): string + { + return $this->longUrl; + } + public function getValidSince(): ?Chronos { return $this->validSince; diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index ddfaa189..286a2fcb 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -201,14 +201,14 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl + public function findOneMatching(array $tags, ShortUrlMeta $meta): ?ShortUrl { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('s') ->from(ShortUrl::class, 's') ->where($qb->expr()->eq('s.longUrl', ':longUrl')) - ->setParameter('longUrl', $url) + ->setParameter('longUrl', $meta->getLongUrl()) ->setMaxResults(1) ->orderBy('s.id'); diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index a0131f6f..53da12d4 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -38,7 +38,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool; - public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl; + public function findOneMatching(array $tags, ShortUrlMeta $meta): ?ShortUrl; public function importedUrlExists(ImportedShlinkUrl $url): bool; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 3ed4d2df..cc411ba0 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -43,18 +43,18 @@ class UrlShortener implements UrlShortenerInterface * @throws InvalidUrlException * @throws Throwable */ - public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl + public function shorten(array $tags, ShortUrlMeta $meta): ShortUrl { // First, check if a short URL exists for all provided params - $existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta); + $existingShortUrl = $this->findExistingShortUrlIfExists($tags, $meta); if ($existingShortUrl !== null) { return $existingShortUrl; } - $this->urlValidator->validateUrl($url, $meta->doValidateUrl()); + $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); - return $this->em->transactional(function () use ($url, $tags, $meta) { - $shortUrl = new ShortUrl($url, $meta, $this->relationResolver); + return $this->em->transactional(function () use ($tags, $meta) { + $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); $this->verifyShortCodeUniqueness($meta, $shortUrl); @@ -64,7 +64,7 @@ class UrlShortener implements UrlShortenerInterface }); } - private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl + private function findExistingShortUrlIfExists(array $tags, ShortUrlMeta $meta): ?ShortUrl { if (! $meta->findIfExists()) { return null; @@ -72,7 +72,7 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($url, $tags, $meta); + return $repo->findOneMatching($tags, $meta); } private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 45b1eb8a..b0916ab3 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -16,5 +16,5 @@ interface UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl; + public function shorten(array $tags, ShortUrlMeta $meta): ShortUrl; } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index ca29ad14..ba095c03 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -31,15 +31,26 @@ class ShortUrlMetaInputFilter extends InputFilter public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; - public function __construct(array $data) + private bool $requireLongUrl; + + public function __construct(array $data, bool $requireLongUrl = false) { + $this->requireLongUrl = $requireLongUrl; $this->initialize(); $this->setData($data); } private function initialize(): void { - $this->add($this->createInput(self::LONG_URL, false)); + $longUrlInput = $this->createInput(self::LONG_URL, $this->requireLongUrl); + $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + Validator\NotEmpty::OBJECT, + Validator\NotEmpty::SPACE, + Validator\NotEmpty::NULL, + Validator\NotEmpty::EMPTY_ARRAY, + Validator\NotEmpty::BOOLEAN, + ])); + $this->add($longUrlInput); $validSince = $this->createInput(self::VALID_SINCE, false); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index eae77154..3231eec4 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -88,9 +88,8 @@ class DomainRepositoryTest extends DatabaseTestCase private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl { - return new ShortUrl( - 'foo', - ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey]), + return ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo']), new class ($domain) implements ShortUrlRelationResolverInterface { private Domain $domain; diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index c942f61d..40205743 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -39,16 +39,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneWithDomainFallbackReturnsProperData(): void { - $regularOne = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'foo'])); + $regularOne = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($regularOne); - $withDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData( - ['domain' => 'example.com', 'customSlug' => 'domain-short-code'], + $withDomain = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['domain' => 'example.com', 'customSlug' => 'domain-short-code', 'longUrl' => 'foo'], )); $this->getEntityManager()->persist($withDomain); - $withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::fromRawData( - ['domain' => 'doma.in', 'customSlug' => 'foo'], + $withDomainDuplicatingRegular = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['domain' => 'doma.in', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -80,7 +80,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase { $count = 5; for ($i = 0; $i < $count; $i++) { - $this->getEntityManager()->persist(new ShortUrl((string) $i)); + $this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); } $this->getEntityManager()->flush(); @@ -93,17 +93,17 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $tag = new Tag('bar'); $this->getEntityManager()->persist($tag); - $foo = new ShortUrl('foo'); + $foo = ShortUrl::withLongUrl('foo'); $foo->setTags(new ArrayCollection([$tag])); $this->getEntityManager()->persist($foo); - $bar = new ShortUrl('bar'); + $bar = ShortUrl::withLongUrl('bar'); $visit = new Visit($bar, Visitor::emptyInstance()); $this->getEntityManager()->persist($visit); $bar->setVisits(new ArrayCollection([$visit])); $this->getEntityManager()->persist($bar); - $foo2 = new ShortUrl('foo_2'); + $foo2 = ShortUrl::withLongUrl('foo_2'); $ref = new ReflectionObject($foo2); $dateProp = $ref->getProperty('dateCreated'); $dateProp->setAccessible(true); @@ -151,7 +151,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase { $urls = ['a', 'z', 'c', 'b']; foreach ($urls as $url) { - $this->getEntityManager()->persist(new ShortUrl($url)); + $this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); } $this->getEntityManager()->flush(); @@ -170,12 +170,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void { - $shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug'])); + $shortUrlWithoutDomain = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), + ); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = new ShortUrl( - 'foo', - ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']), + $shortUrlWithDomain = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -192,12 +193,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneLooksForShortUrlInProperSetOfTables(): void { - $shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug'])); + $shortUrlWithoutDomain = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), + ); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = new ShortUrl( - 'foo', - ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']), + $shortUrlWithDomain = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -214,12 +216,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneMatchingReturnsNullForNonExistingShortUrls(): void { - self::assertNull($this->repo->findOneMatching('', [], ShortUrlMeta::createEmpty())); - self::assertNull($this->repo->findOneMatching('foobar', [], ShortUrlMeta::createEmpty())); - self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::createEmpty())); - self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertNull($this->repo->findOneMatching([], ShortUrlMeta::createEmpty())); + self::assertNull($this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['longUrl' => 'foobar']))); + self::assertNull($this->repo->findOneMatching( + ['foo', 'bar'], + ShortUrlMeta::fromRawData(['longUrl' => 'foobar']), + )); + self::assertNull($this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => Chronos::parse('2020-03-05 20:18:30'), 'customSlug' => 'this_slug_does_not_exist', + 'longUrl' => 'foobar', ]))); } @@ -229,56 +235,65 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $start = Chronos::parse('2020-03-05 20:18:30'); $end = Chronos::parse('2021-03-05 20:18:30'); - $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(['validSince' => $start])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo'])); $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); $this->getEntityManager()->persist($shortUrl); - $shortUrl2 = new ShortUrl('bar', ShortUrlMeta::fromRawData(['validUntil' => $end])); + $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])); $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = new ShortUrl('baz', ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end])); + $shortUrl3 = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end, 'longUrl' => 'baz']), + ); $this->getEntityManager()->persist($shortUrl3); - $shortUrl4 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'validUntil' => $end])); + $shortUrl4 = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'foo']), + ); $this->getEntityManager()->persist($shortUrl4); - $shortUrl5 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['maxVisits' => 3])); + $shortUrl5 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl5); - $shortUrl6 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['domain' => 'doma.in'])); + $shortUrl6 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl6); $this->getEntityManager()->flush(); self::assertSame( $shortUrl, - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])), + $this->repo->findOneMatching( + ['foo', 'bar'], + ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo']), + ), ); self::assertSame( $shortUrl2, - $this->repo->findOneMatching('bar', [], ShortUrlMeta::fromRawData(['validUntil' => $end])), + $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])), ); self::assertSame( $shortUrl3, - $this->repo->findOneMatching('baz', [], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching([], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'validUntil' => $end, + 'longUrl' => 'baz', ])), ); self::assertSame( $shortUrl4, - $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching([], ShortUrlMeta::fromRawData([ 'customSlug' => 'custom', 'validUntil' => $end, + 'longUrl' => 'foo', ])), ); self::assertSame( $shortUrl5, - $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['maxVisits' => 3])), + $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])), ); self::assertSame( $shortUrl6, - $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['domain' => 'doma.in'])), + $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])), ); } @@ -286,25 +301,25 @@ class ShortUrlRepositoryTest extends DatabaseTestCase public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void { $start = Chronos::parse('2020-03-05 20:18:30'); - $meta = ['validSince' => $start, 'maxVisits' => 50]; + $meta = ShortUrlMeta::fromRawData(['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo']); $tags = ['foo', 'bar']; $tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags); - $shortUrl1 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl1 = ShortUrl::fromMeta($meta); $shortUrl1->setTags($tagEntities); $this->getEntityManager()->persist($shortUrl1); - $shortUrl2 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl2 = ShortUrl::fromMeta($meta); $shortUrl2->setTags($tagEntities); $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl3 = ShortUrl::fromMeta($meta); $shortUrl3->setTags($tagEntities); $this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->flush(); - $result = $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)); + $result = $this->repo->findOneMatching($tags, $meta); self::assertSame($shortUrl1, $result); self::assertNotSame($shortUrl2, $result); @@ -332,8 +347,8 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain)); $this->getEntityManager()->persist($rightDomainApiKey); - $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData( - ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority()], + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority(), 'longUrl' => 'foo'], ), new PersistenceShortUrlRelationResolver($this->getEntityManager())); $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); $this->getEntityManager()->persist($shortUrl); @@ -342,45 +357,54 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame( $shortUrl, - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])), + $this->repo->findOneMatching( + ['foo', 'bar'], + ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo']), + ), ); - self::assertSame($shortUrl, $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertSame($shortUrl, $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, + 'longUrl' => 'foo', ]))); - self::assertNull($this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertNull($this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'apiKey' => $otherApiKey, + 'longUrl' => 'foo', ]))); self::assertSame( $shortUrl, - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), + 'longUrl' => 'foo', ])), ); self::assertSame( $shortUrl, - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $rightDomainApiKey, + 'longUrl' => 'foo', ])), ); self::assertSame( $shortUrl, - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $apiKey, + 'longUrl' => 'foo', ])), ); self::assertNull( - $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $wrongDomainApiKey, + 'longUrl' => 'foo', ])), ); } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 59f53b6b..58f146f3 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -62,14 +62,14 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($tags, 3); $secondUrlTags = [$tags[0]]; - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $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 = ShortUrl::createEmpty(); $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); $this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance())); @@ -119,13 +119,12 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3); - $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => ''])); $shortUrl->setTags(new ArrayCollection($firstUrlTags)); $this->getEntityManager()->persist($shortUrl); - $shortUrl2 = new ShortUrl( - '', - ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]), + $shortUrl2 = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'longUrl' => '']), new PersistenceShortUrlRelationResolver($this->getEntityManager()), ); $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 1cc1c895..ebdc2116 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -40,7 +40,7 @@ class VisitRepositoryTest extends DatabaseTestCase */ public function findVisitsReturnsProperVisits(int $blockSize): void { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $this->getEntityManager()->persist($shortUrl); $countIterable = function (iterable $results): int { $resultsCount = 0; @@ -190,9 +190,8 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey1 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($apiKey1); - $shortUrl = new ShortUrl( - '', - ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority()]), + $shortUrl = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), new PersistenceShortUrlRelationResolver($this->getEntityManager()), ); $this->getEntityManager()->persist($shortUrl); @@ -200,13 +199,12 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey2 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($apiKey2); - $shortUrl2 = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $apiKey2])); + $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); $this->getEntityManager()->persist($shortUrl2); $this->createVisitsForShortUrl($shortUrl2, 5); - $shortUrl3 = new ShortUrl( - '', - ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority()]), + $shortUrl3 = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), new PersistenceShortUrlRelationResolver($this->getEntityManager()), ); $this->getEntityManager()->persist($shortUrl3); @@ -225,7 +223,7 @@ class VisitRepositoryTest extends DatabaseTestCase private function createShortUrlsAndVisits(bool $withDomain = true): array { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $domain = 'example.com'; $shortCode = $shortUrl->getShortCode(); $this->getEntityManager()->persist($shortUrl); @@ -233,9 +231,10 @@ class VisitRepositoryTest extends DatabaseTestCase $this->createVisitsForShortUrl($shortUrl); if ($withDomain) { - $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ + $shortUrlWithDomain = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ 'customSlug' => $shortCode, 'domain' => $domain, + 'longUrl' => '', ])); $this->getEntityManager()->persist($shortUrlWithDomain); $this->createVisitsForShortUrl($shortUrlWithDomain, 3); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index b1edd9ec..cae74926 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -43,7 +43,7 @@ class PixelActionTest extends TestCase { $shortCode = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn( - new ShortUrl('http://domain.com/foo/bar'), + ShortUrl::withLongUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); $this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce(); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 76daa406..5593be7c 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -60,7 +60,7 @@ class QrCodeActionTest extends TestCase { $shortCode = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) - ->willReturn(new ShortUrl('')) + ->willReturn(ShortUrl::createEmpty()) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -83,7 +83,9 @@ class QrCodeActionTest extends TestCase string $expectedContentType ): void { $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl('')); + $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + ShortUrl::createEmpty(), + ); $delegate = $this->prophesize(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); @@ -107,7 +109,9 @@ class QrCodeActionTest extends TestCase public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void { $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl('')); + $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + ShortUrl::createEmpty(), + ); $delegate = $this->prophesize(RequestHandlerInterface::class); $resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal()); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 99046e8c..411d9a50 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -54,7 +54,7 @@ class RedirectActionTest extends TestCase public function redirectionIsPerformedToLongUrl(string $expectedUrl, array $query): void { $shortCode = 'abc123'; - $shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing'); + $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( new ShortUrlIdentifier($shortCode, ''), )->willReturn($shortUrl); @@ -104,7 +104,7 @@ class RedirectActionTest extends TestCase public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void { $shortCode = 'abc123'; - $shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing'); + $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { }); diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 9f28c41b..1f652274 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -37,11 +37,11 @@ class ShortUrlTest extends TestCase public function provideInvalidShortUrls(): iterable { yield 'with custom slug' => [ - new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug'])), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', ]; yield 'already persisted' => [ - (new ShortUrl(''))->setId('1'), + ShortUrl::createEmpty()->setId('1'), 'The short code can be regenerated only on new ShortUrls which have not been persisted yet.', ]; } @@ -62,7 +62,7 @@ class ShortUrlTest extends TestCase public function provideValidShortUrls(): iterable { - yield 'no custom slug' => [new ShortUrl('')]; + yield 'no custom slug' => [ShortUrl::createEmpty()]; yield 'imported with custom slug' => [ ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug'), true), ]; @@ -74,8 +74,8 @@ class ShortUrlTest extends TestCase */ public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void { - $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData( - [ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length], + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + [ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], )); self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); diff --git a/module/Core/test/Entity/VisitTest.php b/module/Core/test/Entity/VisitTest.php index 9d75f793..d583c799 100644 --- a/module/Core/test/Entity/VisitTest.php +++ b/module/Core/test/Entity/VisitTest.php @@ -19,7 +19,7 @@ class VisitTest extends TestCase */ public function isProperlyJsonSerialized(?Chronos $date): void { - $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date); self::assertEquals([ 'referer' => 'some site', @@ -41,7 +41,7 @@ class VisitTest extends TestCase */ public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void { - $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', $address), $anonymize); self::assertEquals($expectedAddress, $visit->getRemoteAddr()); } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 8c9119a5..fdb5bfec 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -78,7 +78,7 @@ class LocateShortUrlVisitTest extends TestCase { $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn( - new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4')), + new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')), ); $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow( WrongIpException::class, @@ -125,7 +125,7 @@ class LocateShortUrlVisitTest extends TestCase public function provideNonLocatableVisits(): iterable { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); yield 'null IP' => [new Visit($shortUrl, new Visitor('', '', null))]; yield 'empty IP' => [new Visit($shortUrl, new Visitor('', '', ''))]; @@ -139,7 +139,7 @@ class LocateShortUrlVisitTest extends TestCase public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void { $ipAddr = $originalIpAddress ?? $anonymizedIpAddress; - $visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr)); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123', $originalIpAddress); @@ -171,7 +171,7 @@ class LocateShortUrlVisitTest extends TestCase { $e = GeolocationDbUpdateFailedException::create(true); $ipAddr = '1.2.3.0'; - $visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr)); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123'); @@ -202,7 +202,7 @@ class LocateShortUrlVisitTest extends TestCase { $e = GeolocationDbUpdateFailedException::create(false); $ipAddr = '1.2.3.0'; - $visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr)); + $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index b8e71297..f5b525ea 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -77,7 +77,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = new Visit(new ShortUrl(''), Visitor::emptyInstance()); + $visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()); $update = new Update('', ''); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); @@ -101,7 +101,7 @@ class NotifyVisitToMercureTest extends TestCase public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = new Visit(new ShortUrl(''), Visitor::emptyInstance()); + $visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()); $update = new Update('', ''); $e = new RuntimeException('Error'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index e7021e18..ff382f13 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -79,7 +79,9 @@ class NotifyVisitToWebHooksTest extends TestCase $webhooks = ['foo', 'invalid', 'bar', 'baz']; $invalidWebhooks = ['invalid', 'baz']; - $find = $this->em->find(Visit::class, '1')->willReturn(new Visit(new ShortUrl(''), Visitor::emptyInstance())); + $find = $this->em->find(Visit::class, '1')->willReturn( + new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()), + ); $requestAsync = $this->httpClient->requestAsync( RequestMethodInterface::METHOD_POST, Argument::type('string'), diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index aef2a489..b7382f84 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -28,7 +28,7 @@ class MercureUpdatesGeneratorTest extends TestCase */ public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void { - $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'foo'])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['customSlug' => 'foo', 'longUrl' => ''])); $visit = new Visit($shortUrl, Visitor::emptyInstance()); $update = $this->generator->{$method}($visit); diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 3c45dad9..848bd90b 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -56,6 +56,9 @@ class ShortUrlMetaTest extends TestCase yield [[ ShortUrlMetaInputFilter::CUSTOM_SLUG => ' ', ]]; + yield [[ + ShortUrlMetaInputFilter::LONG_URL => [], + ]]; } /** @@ -64,9 +67,11 @@ class ShortUrlMetaTest extends TestCase */ public function properlyCreatedInstanceReturnsValues(string $customSlug, string $expectedSlug): void { - $meta = ShortUrlMeta::fromRawData( - ['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug], - ); + $meta = ShortUrlMeta::fromRawData([ + 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), + 'customSlug' => $customSlug, + 'longUrl' => '', + ]); self::assertTrue($meta->hasValidSince()); self::assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 449220b4..9e2ca4ec 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -33,8 +33,8 @@ class DeleteShortUrlServiceTest extends TestCase public function setUp(): void { - $shortUrl = (new ShortUrl(''))->setVisits(new ArrayCollection( - map(range(0, 10), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())), + $shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( + map(range(0, 10), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index e7cc0041..038fe457 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -44,7 +44,7 @@ class ShortUrlResolverTest extends TestCase */ public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { - $shortUrl = new ShortUrl('expected_url'); + $shortUrl = ShortUrl::withLongUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); @@ -80,7 +80,7 @@ class ShortUrlResolverTest extends TestCase /** @test */ public function shortCodeToEnabledShortUrlProperlyParsesShortCode(): void { - $shortUrl = new ShortUrl('expected_url'); + $shortUrl = ShortUrl::withLongUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); @@ -118,7 +118,7 @@ class ShortUrlResolverTest extends TestCase $now = Chronos::now(); yield 'maxVisits reached' => [(function () { - $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => 3])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), fn () => new Visit($shortUrl, Visitor::emptyInstance()), @@ -126,16 +126,17 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; - yield 'future validSince' => [new ShortUrl('', ShortUrlMeta::fromRawData([ - 'validSince' => $now->addMonth()->toAtomString(), - ]))]; - yield 'past validUntil' => [new ShortUrl('', ShortUrlMeta::fromRawData([ - 'validUntil' => $now->subMonth()->toAtomString(), - ]))]; + yield 'future validSince' => [ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => ''], + ))]; + yield 'past validUntil' => [ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => ''], + ))]; yield 'mixed' => [(function () use ($now) { - $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData([ + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ 'maxVisits' => 3, 'validUntil' => $now->subMonth()->toAtomString(), + 'longUrl' => '', ])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 5ced9b1a..d056b91b 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -58,10 +58,10 @@ class ShortUrlServiceTest extends TestCase public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ - new ShortUrl(''), - new ShortUrl(''), - new ShortUrl(''), - new ShortUrl(''), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), ]; $repo = $this->prophesize(ShortUrlRepository::class); @@ -106,7 +106,7 @@ class ShortUrlServiceTest extends TestCase ?ApiKey $apiKey ): void { $originalLongUrl = 'originalLongUrl'; - $shortUrl = new ShortUrl($originalLongUrl); + $shortUrl = ShortUrl::withLongUrl($originalLongUrl); $findShortUrl = $this->urlResolver->resolveShortUrl( new ShortUrlIdentifier('abc123'), diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 9d8c5273..fb4affc7 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -69,9 +69,8 @@ class UrlShortenerTest extends TestCase public function urlIsProperlyShortened(): void { $shortUrl = $this->urlShortener->shorten( - 'http://foobar.com/12345/hello?foo=bar', [], - ShortUrlMeta::createEmpty(), + ShortUrlMeta::fromRawData(['longUrl' => 'http://foobar.com/12345/hello?foo=bar']), ); self::assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); @@ -85,28 +84,22 @@ class UrlShortenerTest extends TestCase $ensureUniqueness->shouldBeCalledOnce(); $this->expectException(NonUniqueSlugException::class); - $this->urlShortener->shorten( - 'http://foobar.com/12345/hello?foo=bar', - [], - ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug']), - ); + $this->urlShortener->shorten([], ShortUrlMeta::fromRawData( + ['customSlug' => 'custom-slug', 'longUrl' => 'http://foobar.com/12345/hello?foo=bar'], + )); } /** * @test * @dataProvider provideExistingShortUrls */ - public function existingShortUrlIsReturnedWhenRequested( - string $url, - array $tags, - ShortUrlMeta $meta, - ShortUrl $expected - ): void { + public function existingShortUrlIsReturnedWhenRequested(array $tags, ShortUrlMeta $meta, ShortUrl $expected): void + { $repo = $this->prophesize(ShortUrlRepository::class); $findExisting = $repo->findOneMatching(Argument::cetera())->willReturn($expected); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlShortener->shorten($url, $tags, $meta); + $result = $this->urlShortener->shorten($tags, $meta); $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); @@ -119,52 +112,58 @@ class UrlShortenerTest extends TestCase { $url = 'http://foo.com'; - yield [$url, [], ShortUrlMeta::fromRawData(['findIfExists' => true]), new ShortUrl($url)]; - yield [$url, [], ShortUrlMeta::fromRawData( - ['findIfExists' => true, 'customSlug' => 'foo'], - ), new ShortUrl($url)]; - yield [ + yield [[], ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url]), ShortUrl::withLongUrl( $url, + )]; + yield [[], ShortUrlMeta::fromRawData( + ['findIfExists' => true, 'customSlug' => 'foo', 'longUrl' => $url], + ), ShortUrl::withLongUrl($url)]; + yield [ ['foo', 'bar'], - ShortUrlMeta::fromRawData(['findIfExists' => true]), - (new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])), + ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url]), + ShortUrl::withLongUrl($url)->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])), ]; yield [ - $url, [], - ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3]), - new ShortUrl($url, ShortUrlMeta::fromRawData(['maxVisits' => 3])), + ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => $url])), ]; yield [ - $url, [], - ShortUrlMeta::fromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]), - new ShortUrl($url, ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2017-01-01')])), + ShortUrlMeta::fromRawData( + ['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url], + ), + ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url]), + ), ]; yield [ - $url, [], - ShortUrlMeta::fromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]), - new ShortUrl($url, ShortUrlMeta::fromRawData(['validUntil' => Chronos::parse('2017-01-01')])), + ShortUrlMeta::fromRawData( + ['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url], + ), + ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url]), + ), ]; yield [ - $url, [], - ShortUrlMeta::fromRawData(['findIfExists' => true, 'domain' => 'example.com']), - new ShortUrl($url, ShortUrlMeta::fromRawData(['domain' => 'example.com'])), + ShortUrlMeta::fromRawData(['findIfExists' => true, 'domain' => 'example.com', 'longUrl' => $url]), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['domain' => 'example.com', 'longUrl' => $url])), ]; yield [ - $url, ['baz', 'foo', 'bar'], ShortUrlMeta::fromRawData([ 'findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'maxVisits' => 4, + 'longUrl' => $url, ]), - (new ShortUrl($url, ShortUrlMeta::fromRawData([ + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ 'validUntil' => Chronos::parse('2017-01-01'), 'maxVisits' => 4, - ])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])), + 'longUrl' => $url, + ]))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])), ]; } } diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 1efe61df..9b627a17 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -56,7 +56,7 @@ class VisitsTrackerTest extends TestCase $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce(); $this->em->flush()->shouldBeCalledOnce(); - $this->visitsTracker->track(new ShortUrl($shortCode), Visitor::emptyInstance()); + $this->visitsTracker->track(ShortUrl::withLongUrl($shortCode), Visitor::emptyInstance()); $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); } @@ -73,7 +73,7 @@ class VisitsTrackerTest extends TestCase $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); - $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( $list, @@ -129,7 +129,7 @@ class VisitsTrackerTest extends TestCase $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); $spec = $apiKey === null ? null : $apiKey->spec(); - $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); diff --git a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/Transformer/ShortUrlDataTransformerTest.php index 9abe5f1a..0a621799 100644 --- a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/Transformer/ShortUrlDataTransformerTest.php @@ -37,18 +37,23 @@ class ShortUrlDataTransformerTest extends TestCase $maxVisits = random_int(1, 1000); $now = Chronos::now(); - yield 'no metadata' => [new ShortUrl('', ShortUrlMeta::createEmpty()), [ + yield 'no metadata' => [ShortUrl::createEmpty(), [ 'validSince' => null, 'validUntil' => null, 'maxVisits' => null, ]]; - yield 'max visits only' => [new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => $maxVisits])), [ + yield 'max visits only' => [ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'maxVisits' => $maxVisits, + 'longUrl' => '', + ])), [ 'validSince' => null, 'validUntil' => null, 'maxVisits' => $maxVisits, ]]; yield 'max visits and valid since' => [ - new ShortUrl('', ShortUrlMeta::fromRawData(['validSince' => $now, 'maxVisits' => $maxVisits])), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => ''], + )), [ 'validSince' => $now->toAtomString(), 'validUntil' => null, @@ -56,8 +61,8 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'both dates' => [ - new ShortUrl('', ShortUrlMeta::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(10)], + ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => ''], )), [ 'validSince' => $now->toAtomString(), @@ -66,8 +71,8 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'everything' => [ - new ShortUrl('', ShortUrlMeta::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits], + ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits, 'longUrl' => ''], )), [ 'validSince' => $now->toAtomString(), diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index e9f1a2d5..96caf968 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -57,7 +57,7 @@ class VisitLocatorTest extends TestCase ): void { $unlocatedVisits = map( range(1, 200), - fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), + fn (int $i) => new Visit(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), ); $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); @@ -107,7 +107,7 @@ class VisitLocatorTest extends TestCase bool $isNonLocatableAddress ): void { $unlocatedVisits = [ - new Visit(new ShortUrl('foo'), Visitor::emptyInstance()), + new Visit(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), ]; $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 8d4ea777..2da2044a 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -27,11 +27,10 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction public function handle(Request $request): Response { $shortUrlData = $this->buildShortUrlData($request); - $longUrl = $shortUrlData->getLongUrl(); $tags = $shortUrlData->getTags(); $shortUrlMeta = $shortUrlData->getMeta(); - $shortUrl = $this->urlShortener->shorten($longUrl, $tags, $shortUrlMeta); + $shortUrl = $this->urlShortener->shorten($tags, $shortUrlMeta); $transformer = new ShortUrlDataTransformer($this->domainConfig); return new JsonResponse($transformer->transform($shortUrl)); diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 28941579..834fa541 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -22,15 +22,9 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction protected function buildShortUrlData(Request $request): CreateShortUrlData { $payload = (array) $request->getParsedBody(); - if (! isset($payload['longUrl'])) { - throw ValidationException::fromArray([ - 'longUrl' => 'A URL was not provided', - ]); - } - $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); $meta = ShortUrlMeta::fromRawData($payload); - return new CreateShortUrlData($payload['longUrl'], (array) ($payload['tags'] ?? []), $meta); + return new CreateShortUrlData((array) ($payload['tags'] ?? []), $meta); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index b8bd86aa..edba3184 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; @@ -20,15 +19,10 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction { $query = $request->getQueryParams(); $longUrl = $query['longUrl'] ?? null; - - if ($longUrl === null) { - throw ValidationException::fromArray([ - 'longUrl' => 'A URL was not provided', - ]); - } - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ + + return new CreateShortUrlData([], ShortUrlMeta::fromRawData([ + ShortUrlMetaInputFilter::LONG_URL => $longUrl, ShortUrlMetaInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN), diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 60cffddd..54ea0218 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -212,10 +212,12 @@ class CreateShortUrlTest extends ApiTestCase yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io } - /** @test */ - public function failsToCreateShortUrlWithInvalidLongUrl(): void + /** + * @test + * @dataProvider provideInvalidUrls + */ + public function failsToCreateShortUrlWithInvalidLongUrl(string $url): void { - $url = 'https://this-has-to-be-invalid.com'; $expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url); [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url]); @@ -228,6 +230,25 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals($url, $payload['url']); } + public function provideInvalidUrls(): iterable + { + yield 'empty URL' => ['']; + yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com']; + } + + /** @test */ + public function failsToCreateShortUrlWithoutLongUrl(): void + { + $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => []]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + } + /** @test */ public function defaultDomainIsDroppedIfProvided(): void { diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 954d2059..390a2144 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -27,44 +27,46 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf $authorApiKey = $this->getReference('author_api_key'); $abcShortUrl = $this->setShortUrlDate( - new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData( - ['customSlug' => 'abc123', 'apiKey' => $authorApiKey], + ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['customSlug' => 'abc123', 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io'], )), '2018-05-01', ); $manager->persist($abcShortUrl); - $defShortUrl = $this->setShortUrlDate(new ShortUrl( - 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - ShortUrlMeta::fromRawData( - ['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456', 'apiKey' => $authorApiKey], - ), - ), '2019-01-01 00:00:10'); + $defShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'validSince' => Chronos::parse('2020-05-01'), + 'customSlug' => 'def456', + 'apiKey' => $authorApiKey, + 'longUrl' => + 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + ])), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); - $customShortUrl = $this->setShortUrlDate(new ShortUrl( - 'https://shlink.io', - ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2, 'apiKey' => $authorApiKey]), - ), '2019-01-01 00:00:20'); + $customShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['customSlug' => 'custom', 'maxVisits' => 2, 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io'], + )), '2019-01-01 00:00:20'); $manager->persist($customShortUrl); $ghiShortUrl = $this->setShortUrlDate( - new ShortUrl('https://shlink.io/documentation/', ShortUrlMeta::fromRawData(['customSlug' => 'ghi789'])), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['customSlug' => 'ghi789', 'longUrl' => 'https://shlink.io/documentation/'], + )), '2018-05-01', ); $manager->persist($ghiShortUrl); - $withDomainDuplicatingShortCode = $this->setShortUrlDate(new ShortUrl( - 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', - ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']), - new PersistenceShortUrlRelationResolver($manager), - ), '2019-01-01 00:00:30'); + $withDomainDuplicatingShortCode = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'domain' => 'example.com', + 'customSlug' => 'ghi789', + 'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-' + . 'source-software-projects/', + ]), new PersistenceShortUrlRelationResolver($manager)), '2019-01-01 00:00:30'); $manager->persist($withDomainDuplicatingShortCode); - $withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl( - 'https://google.com', - ShortUrlMeta::fromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']), - ), '2018-10-20'); + $withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData( + ['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain', 'longUrl' => 'https://google.com'], + )), '2018-10-20'); $manager->persist($withDomainAndSlugShortUrl); $manager->flush(); diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 80ccfc17..2388a4c8 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -39,24 +39,22 @@ class CreateShortUrlActionTest extends TestCase } /** @test */ - public function missingLongUrlParamReturnsError(): void - { - $this->expectException(ValidationException::class); - $this->action->handle(new ServerRequest()); - } - - /** - * @test - * @dataProvider provideRequestBodies - */ - public function properShortcodeConversionReturnsData(array $body, array $expectedMeta): void + public function properShortcodeConversionReturnsData(): void { $apiKey = new ApiKey(); - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); + $expectedMeta = $body = [ + 'longUrl' => 'http://www.domain.com/foo/bar', + 'validSince' => Chronos::now()->toAtomString(), + 'validUntil' => Chronos::now()->toAtomString(), + 'customSlug' => 'foo-bar-baz', + 'maxVisits' => 50, + 'findIfExists' => true, + 'domain' => 'my-domain.com', + ]; $expectedMeta['apiKey'] = $apiKey; $shorten = $this->urlShortener->shorten( - Argument::type('string'), Argument::type('array'), ShortUrlMeta::fromRawData($expectedMeta), )->willReturn($shortUrl); @@ -70,29 +68,13 @@ class CreateShortUrlActionTest extends TestCase $shorten->shouldHaveBeenCalledOnce(); } - public function provideRequestBodies(): iterable - { - $fullMeta = [ - 'longUrl' => 'http://www.domain.com/foo/bar', - 'validSince' => Chronos::now()->toAtomString(), - 'validUntil' => Chronos::now()->toAtomString(), - 'customSlug' => 'foo-bar-baz', - 'maxVisits' => 50, - 'findIfExists' => true, - 'domain' => 'my-domain.com', - ]; - - yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], []]; - yield 'all data' => [$fullMeta, $fullMeta]; - } - /** * @test * @dataProvider provideInvalidDomains */ public function anInvalidDomainReturnsError(string $domain): void { - $shortUrl = new ShortUrl(''); + $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); $request = (new ServerRequest())->withParsedBody([ diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 5e9eadf7..ad482098 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -49,7 +49,7 @@ class EditShortUrlActionTest extends TestCase 'maxVisits' => 5, ]); $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willReturn( - new ShortUrl(''), + ShortUrl::createEmpty(), ); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index 9c72dd91..60d1d093 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -45,7 +45,7 @@ class EditShortUrlTagsActionTest extends TestCase new ShortUrlIdentifier($shortCode), [], Argument::type(ApiKey::class), - )->willReturn(new ShortUrl('')) + )->willReturn(ShortUrl::createEmpty()) ->shouldBeCalledOnce(); $response = $this->action->handle( diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index f4c49a60..2f47089f 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -35,7 +35,7 @@ class ResolveShortUrlActionTest extends TestCase $shortCode = 'abc123'; $apiKey = new ApiKey(); $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( - new ShortUrl('http://domain.com/foo/bar'), + ShortUrl::withLongUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 0973a198..41d37f18 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -5,13 +5,10 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction; @@ -38,16 +35,6 @@ class SingleStepCreateShortUrlActionTest extends TestCase ); } - /** @test */ - public function errorResponseIsReturnedIfNoUrlIsProvided(): void - { - $request = new ServerRequest(); - - $this->expectException(ValidationException::class); - - $this->action->handle($request); - } - /** @test */ public function properDataIsPassedWhenGeneratingShortCode(): void { @@ -57,13 +44,9 @@ class SingleStepCreateShortUrlActionTest extends TestCase 'longUrl' => 'http://foobar.com', ])->withAttribute(ApiKey::class, $apiKey); $generateShortCode = $this->urlShortener->shorten( - Argument::that(function (string $argument): bool { - Assert::assertEquals('http://foobar.com', $argument); - return true; - }), [], - ShortUrlMeta::fromRawData(['apiKey' => $apiKey]), - )->willReturn(new ShortUrl('')); + ShortUrlMeta::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']), + )->willReturn(ShortUrl::createEmpty()); $resp = $this->action->handle($request); From 903ef8e249010d1fd12fe27973f4312f5238d6ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 18:24:14 +0100 Subject: [PATCH 021/115] Normalized some filtering --- module/Core/src/Entity/ShortUrl.php | 11 ++++++++--- module/Core/src/Model/ShortUrlEdit.php | 1 - module/Core/src/Model/ShortUrlMeta.php | 18 ++++++++++++++---- .../src/Validation/ShortUrlMetaInputFilter.php | 7 ++++++- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 3c076585..76df360e 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -40,6 +40,10 @@ class ShortUrl extends AbstractEntity private ?string $importOriginalShortCode = null; private ?ApiKey $authorApiKey = null; + private function __construct() + { + } + public static function createEmpty(): self { return self::fromMeta(ShortUrlMeta::createEmpty()); @@ -219,9 +223,10 @@ class ShortUrl extends AbstractEntity public function toString(array $domainConfig): string { - return (string) (new Uri())->withPath($this->shortCode) - ->withScheme($domainConfig['schema'] ?? 'http') - ->withHost($this->resolveDomain($domainConfig['hostname'] ?? '')); + return (new Uri())->withPath($this->shortCode) + ->withScheme($domainConfig['schema'] ?? 'http') + ->withHost($this->resolveDomain($domainConfig['hostname'] ?? '')) + ->__toString(); } private function resolveDomain(string $fallback = ''): string diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index 67300682..bb2aab33 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -25,7 +25,6 @@ final class ShortUrlEdit private ?int $maxVisits = null; private ?bool $validateUrl = null; - // Enforce named constructors private function __construct() { } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 9291a5e6..1286043b 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -27,18 +27,18 @@ final class ShortUrlMeta private int $shortCodeLength = 5; private ?bool $validateUrl = null; private ?ApiKey $apiKey = null; + private array $tags = []; - // Enforce named constructors private function __construct() { } public static function createEmpty(): self { - $meta = new self(); - $meta->longUrl = ''; + $instance = new self(); + $instance->longUrl = ''; - return $meta; + return $instance; } /** @@ -48,6 +48,7 @@ final class ShortUrlMeta { $instance = new self(); $instance->validateAndInit($data); + return $instance; } @@ -74,6 +75,7 @@ final class ShortUrlMeta ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH; $this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY); + $this->tags = $inputFilter->getValue(ShortUrlMetaInputFilter::TAGS); } public function getLongUrl(): string @@ -150,4 +152,12 @@ final class ShortUrlMeta { return $this->apiKey; } + + /** + * @return string[] + */ + public function getTags(): array + { + return $this->tags; + } } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index ba095c03..1131110d 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -13,6 +13,8 @@ use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function is_numeric; + use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; @@ -30,6 +32,7 @@ class ShortUrlMetaInputFilter extends InputFilter public const LONG_URL = 'longUrl'; public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; + public const TAGS = 'tags'; private bool $requireLongUrl; @@ -90,12 +93,14 @@ class ShortUrlMetaInputFilter extends InputFilter ->setRequired(false) ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); $this->add($apiKeyInput); + + $this->add($this->createArrayInput(self::TAGS, false)); } private function createPositiveNumberInput(string $name, int $min = 1): Input { $input = $this->createInput($name, false); - $input->getValidatorChain()->attach(new Validator\Digits()) + $input->getValidatorChain()->attach(new Validator\Callback(fn ($value) => is_numeric($value))) ->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true])); return $input; From 3f2bd657e13e5249040cf0a61dc2d63052556c41 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 18:55:25 +0100 Subject: [PATCH 022/115] Used input factory methods from shlink-common when possible --- composer.json | 2 +- .../Validation/ShortUrlMetaInputFilter.php | 17 +++---------- .../Validation/ShortUrlsParamsInputFilter.php | 24 +++---------------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/composer.json b/composer.json index 0c397471..8776d34d 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-main#cab9f39 as 3.5", + "shlinkio/shlink-common": "dev-main#b889f5d as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.1", diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index 1131110d..a57b402c 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -13,8 +13,6 @@ use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function is_numeric; - use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; @@ -77,8 +75,8 @@ class ShortUrlMetaInputFilter extends InputFilter ])); $this->add($customSlug); - $this->add($this->createPositiveNumberInput(self::MAX_VISITS)); - $this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH)); + $this->add($this->createNumericInput(self::MAX_VISITS, false)); + $this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, false, MIN_SHORT_CODES_LENGTH)); $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); @@ -94,15 +92,6 @@ class ShortUrlMetaInputFilter extends InputFilter ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); $this->add($apiKeyInput); - $this->add($this->createArrayInput(self::TAGS, false)); - } - - private function createPositiveNumberInput(string $name, int $min = 1): Input - { - $input = $this->createInput($name, false); - $input->getValidatorChain()->attach(new Validator\Callback(fn ($value) => is_numeric($value))) - ->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true])); - - return $input; + $this->add($this->createTagsInput(self::TAGS, false)); } } diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php index e9a292d0..871995dd 100644 --- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php @@ -4,14 +4,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Validation; -use Laminas\Filter; -use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; -use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; -use function is_numeric; - class ShortUrlsParamsInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -36,22 +31,9 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add($this->createInput(self::SEARCH_TERM, false)); - $this->add($this->createNumericInput(self::PAGE, 1)); + $this->add($this->createNumericInput(self::PAGE, false)); + $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, -1)); - $tags = $this->createArrayInput(self::TAGS, false); - $tags->getFilterChain()->attach(new Filter\StringToLower()) - ->attach(new Filter\PregReplace(['pattern' => '/ /', 'replacement' => '-'])); - $this->add($tags); - - $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, -1)); - } - - private function createNumericInput(string $name, int $min): Input - { - $input = $this->createInput($name, false); - $input->getValidatorChain()->attach(new Validator\Callback(fn ($value) => is_numeric($value))) - ->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true])); - - return $input; + $this->add($this->createTagsInput(self::TAGS, false)); } } From 063ee9c195473248c9fac85b6d047b34434ed02a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Jan 2021 19:17:12 +0100 Subject: [PATCH 023/115] Inlcuded tags as part of the ShortUrlMeta --- .../ShortUrl/GenerateShortUrlCommand.php | 3 +- .../ShortUrl/GenerateShortUrlCommandTest.php | 7 ++- module/Core/src/Model/CreateShortUrlData.php | 30 ------------ .../src/Repository/ShortUrlRepository.php | 3 +- .../ShortUrlRepositoryInterface.php | 2 +- module/Core/src/Service/UrlShortener.php | 13 +++-- .../src/Service/UrlShortenerInterface.php | 3 +- .../Repository/ShortUrlRepositoryTest.php | 49 +++++++++++-------- module/Core/test/Service/UrlShortenerTest.php | 20 +++----- .../ShortUrl/AbstractCreateShortUrlAction.php | 10 ++-- .../Action/ShortUrl/CreateShortUrlAction.php | 6 +-- .../SingleStepCreateShortUrlAction.php | 7 ++- .../ShortUrl/CreateShortUrlActionTest.php | 5 +- .../SingleStepCreateShortUrlActionTest.php | 1 - 14 files changed, 60 insertions(+), 99 deletions(-) delete mode 100644 module/Core/src/Model/CreateShortUrlData.php diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 6fd82c1a..68b013b6 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -145,7 +145,7 @@ class GenerateShortUrlCommand extends BaseCommand $doValidateUrl = $this->doValidateUrl($input); try { - $shortUrl = $this->urlShortener->shorten($tags, ShortUrlMeta::fromRawData([ + $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([ ShortUrlMetaInputFilter::LONG_URL => $longUrl, ShortUrlMetaInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'), ShortUrlMetaInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'), @@ -158,6 +158,7 @@ class GenerateShortUrlCommand extends BaseCommand ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl, + ShortUrlMetaInputFilter::TAGS => $tags, ])); $io->writeln([ diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 48d9421b..f2e8d610 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -91,11 +91,11 @@ class GenerateShortUrlCommandTest extends TestCase { $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten( - Argument::that(function (array $tags) { + Argument::that(function (ShortUrlMeta $meta) { + $tags = $meta->getTags(); Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); - return $tags; + return true; }), - Argument::cetera(), )->willReturn($shortUrl); $this->commandTester->execute([ @@ -117,7 +117,6 @@ class GenerateShortUrlCommandTest extends TestCase { $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten( - Argument::type('array'), Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) { Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); return $meta; diff --git a/module/Core/src/Model/CreateShortUrlData.php b/module/Core/src/Model/CreateShortUrlData.php deleted file mode 100644 index d944ac42..00000000 --- a/module/Core/src/Model/CreateShortUrlData.php +++ /dev/null @@ -1,30 +0,0 @@ -tags = $tags; - $this->meta = $meta ?? ShortUrlMeta::createEmpty(); - } - - /** - * @return string[] - */ - public function getTags(): array - { - return $this->tags; - } - - public function getMeta(): ShortUrlMeta - { - return $this->meta; - } -} diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 286a2fcb..d7ab2d66 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -201,7 +201,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(array $tags, ShortUrlMeta $meta): ?ShortUrl + public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -239,6 +239,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $this->applySpecification($qb, $apiKey->spec(), 's'); } + $tags = $meta->getTags(); $tagsAmount = count($tags); if ($tagsAmount === 0) { return $qb->getQuery()->getOneOrNullResult(); diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 53da12d4..e5662e20 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -38,7 +38,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool; - public function findOneMatching(array $tags, ShortUrlMeta $meta): ?ShortUrl; + public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl; public function importedUrlExists(ImportedShlinkUrl $url): bool; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index cc411ba0..08e64e60 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -38,24 +38,23 @@ class UrlShortener implements UrlShortenerInterface } /** - * @param string[] $tags * @throws NonUniqueSlugException * @throws InvalidUrlException * @throws Throwable */ - public function shorten(array $tags, ShortUrlMeta $meta): ShortUrl + public function shorten(ShortUrlMeta $meta): ShortUrl { // First, check if a short URL exists for all provided params - $existingShortUrl = $this->findExistingShortUrlIfExists($tags, $meta); + $existingShortUrl = $this->findExistingShortUrlIfExists($meta); if ($existingShortUrl !== null) { return $existingShortUrl; } $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); - return $this->em->transactional(function () use ($tags, $meta) { + return $this->em->transactional(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); - $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); + $shortUrl->setTags($this->tagNamesToEntities($this->em, $meta->getTags())); $this->verifyShortCodeUniqueness($meta, $shortUrl); $this->em->persist($shortUrl); @@ -64,7 +63,7 @@ class UrlShortener implements UrlShortenerInterface }); } - private function findExistingShortUrlIfExists(array $tags, ShortUrlMeta $meta): ?ShortUrl + private function findExistingShortUrlIfExists(ShortUrlMeta $meta): ?ShortUrl { if (! $meta->findIfExists()) { return null; @@ -72,7 +71,7 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($tags, $meta); + return $repo->findOneMatching($meta); } private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index b0916ab3..eb335e40 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -12,9 +12,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; interface UrlShortenerInterface { /** - * @param string[] $tags * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(array $tags, ShortUrlMeta $meta): ShortUrl; + public function shorten(ShortUrlMeta $meta): ShortUrl; } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 40205743..6802d093 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -216,16 +216,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneMatchingReturnsNullForNonExistingShortUrls(): void { - self::assertNull($this->repo->findOneMatching([], ShortUrlMeta::createEmpty())); - self::assertNull($this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['longUrl' => 'foobar']))); + self::assertNull($this->repo->findOneMatching(ShortUrlMeta::createEmpty())); + self::assertNull($this->repo->findOneMatching(ShortUrlMeta::fromRawData(['longUrl' => 'foobar']))); self::assertNull($this->repo->findOneMatching( - ['foo', 'bar'], - ShortUrlMeta::fromRawData(['longUrl' => 'foobar']), + ShortUrlMeta::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]), )); - self::assertNull($this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertNull($this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => Chronos::parse('2020-03-05 20:18:30'), 'customSlug' => 'this_slug_does_not_exist', 'longUrl' => 'foobar', + 'tags' => ['foo', 'bar'], ]))); } @@ -263,17 +263,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame( $shortUrl, $this->repo->findOneMatching( - ['foo', 'bar'], - ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo']), + ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), ), ); self::assertSame( $shortUrl2, - $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])), + $this->repo->findOneMatching(ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])), ); self::assertSame( $shortUrl3, - $this->repo->findOneMatching([], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'validUntil' => $end, 'longUrl' => 'baz', @@ -281,7 +280,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ); self::assertSame( $shortUrl4, - $this->repo->findOneMatching([], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'foo', @@ -289,11 +288,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ); self::assertSame( $shortUrl5, - $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])), + $this->repo->findOneMatching(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])), ); self::assertSame( $shortUrl6, - $this->repo->findOneMatching([], ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])), + $this->repo->findOneMatching(ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])), ); } @@ -304,6 +303,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $meta = ShortUrlMeta::fromRawData(['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo']); $tags = ['foo', 'bar']; $tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags); + $metaWithTags = ShortUrlMeta::fromRawData( + ['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo', 'tags' => $tags], + ); $shortUrl1 = ShortUrl::fromMeta($meta); $shortUrl1->setTags($tagEntities); @@ -319,7 +321,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findOneMatching($tags, $meta); + $result = $this->repo->findOneMatching($metaWithTags); self::assertSame($shortUrl1, $result); self::assertNotSame($shortUrl2, $result); @@ -358,53 +360,58 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame( $shortUrl, $this->repo->findOneMatching( - ['foo', 'bar'], - ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo']), + ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), ), ); - self::assertSame($shortUrl, $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ]))); - self::assertNull($this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + self::assertNull($this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'apiKey' => $otherApiKey, 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ]))); self::assertSame( $shortUrl, - $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ])), ); self::assertSame( $shortUrl, - $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $rightDomainApiKey, 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ])), ); self::assertSame( $shortUrl, - $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ])), ); self::assertNull( - $this->repo->findOneMatching(['foo', 'bar'], ShortUrlMeta::fromRawData([ + $this->repo->findOneMatching(ShortUrlMeta::fromRawData([ 'validSince' => $start, 'domain' => $rightDomain->getAuthority(), 'apiKey' => $wrongDomainApiKey, 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], ])), ); } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index fb4affc7..caa224b6 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -69,7 +69,6 @@ class UrlShortenerTest extends TestCase public function urlIsProperlyShortened(): void { $shortUrl = $this->urlShortener->shorten( - [], ShortUrlMeta::fromRawData(['longUrl' => 'http://foobar.com/12345/hello?foo=bar']), ); @@ -84,7 +83,7 @@ class UrlShortenerTest extends TestCase $ensureUniqueness->shouldBeCalledOnce(); $this->expectException(NonUniqueSlugException::class); - $this->urlShortener->shorten([], ShortUrlMeta::fromRawData( + $this->urlShortener->shorten(ShortUrlMeta::fromRawData( ['customSlug' => 'custom-slug', 'longUrl' => 'http://foobar.com/12345/hello?foo=bar'], )); } @@ -93,13 +92,13 @@ class UrlShortenerTest extends TestCase * @test * @dataProvider provideExistingShortUrls */ - public function existingShortUrlIsReturnedWhenRequested(array $tags, ShortUrlMeta $meta, ShortUrl $expected): void + public function existingShortUrlIsReturnedWhenRequested(ShortUrlMeta $meta, ShortUrl $expected): void { $repo = $this->prophesize(ShortUrlRepository::class); $findExisting = $repo->findOneMatching(Argument::cetera())->willReturn($expected); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlShortener->shorten($tags, $meta); + $result = $this->urlShortener->shorten($meta); $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); @@ -112,24 +111,21 @@ class UrlShortenerTest extends TestCase { $url = 'http://foo.com'; - yield [[], ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url]), ShortUrl::withLongUrl( + yield [ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url]), ShortUrl::withLongUrl( $url, )]; - yield [[], ShortUrlMeta::fromRawData( + yield [ShortUrlMeta::fromRawData( ['findIfExists' => true, 'customSlug' => 'foo', 'longUrl' => $url], ), ShortUrl::withLongUrl($url)]; yield [ - ['foo', 'bar'], - ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url]), + ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url, 'tags' => ['foo', 'bar']]), ShortUrl::withLongUrl($url)->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])), ]; yield [ - [], ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]), ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => $url])), ]; yield [ - [], ShortUrlMeta::fromRawData( ['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url], ), @@ -138,7 +134,6 @@ class UrlShortenerTest extends TestCase ), ]; yield [ - [], ShortUrlMeta::fromRawData( ['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url], ), @@ -147,17 +142,16 @@ class UrlShortenerTest extends TestCase ), ]; yield [ - [], ShortUrlMeta::fromRawData(['findIfExists' => true, 'domain' => 'example.com', 'longUrl' => $url]), ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['domain' => 'example.com', 'longUrl' => $url])), ]; yield [ - ['baz', 'foo', 'bar'], ShortUrlMeta::fromRawData([ 'findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'maxVisits' => 4, 'longUrl' => $url, + 'tags' => ['baz', 'foo', 'bar'], ]), ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ 'validUntil' => Chronos::parse('2017-01-01'), diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 2da2044a..cfec8cac 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -8,7 +8,7 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\CreateShortUrlData; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -26,11 +26,9 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction public function handle(Request $request): Response { - $shortUrlData = $this->buildShortUrlData($request); - $tags = $shortUrlData->getTags(); - $shortUrlMeta = $shortUrlData->getMeta(); + $shortUrlMeta = $this->buildShortUrlData($request); - $shortUrl = $this->urlShortener->shorten($tags, $shortUrlMeta); + $shortUrl = $this->urlShortener->shorten($shortUrlMeta); $transformer = new ShortUrlDataTransformer($this->domainConfig); return new JsonResponse($transformer->transform($shortUrl)); @@ -39,5 +37,5 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction /** * @throws ValidationException */ - abstract protected function buildShortUrlData(Request $request): CreateShortUrlData; + abstract protected function buildShortUrlData(Request $request): ShortUrlMeta; } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 834fa541..58a295ab 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -19,12 +18,11 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction /** * @throws ValidationException */ - protected function buildShortUrlData(Request $request): CreateShortUrlData + protected function buildShortUrlData(Request $request): ShortUrlMeta { $payload = (array) $request->getParsedBody(); $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); - $meta = ShortUrlMeta::fromRawData($payload); - return new CreateShortUrlData((array) ($payload['tags'] ?? []), $meta); + return ShortUrlMeta::fromRawData($payload); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index edba3184..360e9d72 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -15,17 +14,17 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction protected const ROUTE_PATH = '/short-urls/shorten'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - protected function buildShortUrlData(Request $request): CreateShortUrlData + protected function buildShortUrlData(Request $request): ShortUrlMeta { $query = $request->getQueryParams(); $longUrl = $query['longUrl'] ?? null; $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - return new CreateShortUrlData([], ShortUrlMeta::fromRawData([ + return ShortUrlMeta::fromRawData([ ShortUrlMetaInputFilter::LONG_URL => $longUrl, ShortUrlMetaInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN), - ])); + ]); } } diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 2388a4c8..4d4d5468 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -54,10 +54,7 @@ class CreateShortUrlActionTest extends TestCase ]; $expectedMeta['apiKey'] = $apiKey; - $shorten = $this->urlShortener->shorten( - Argument::type('array'), - ShortUrlMeta::fromRawData($expectedMeta), - )->willReturn($shortUrl); + $shorten = $this->urlShortener->shorten(ShortUrlMeta::fromRawData($expectedMeta))->willReturn($shortUrl); $request = ServerRequestFactory::fromGlobals()->withParsedBody($body)->withAttribute(ApiKey::class, $apiKey); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 41d37f18..9dbbb716 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -44,7 +44,6 @@ class SingleStepCreateShortUrlActionTest extends TestCase 'longUrl' => 'http://foobar.com', ])->withAttribute(ApiKey::class, $apiKey); $generateShortCode = $this->urlShortener->shorten( - [], ShortUrlMeta::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']), )->willReturn(ShortUrl::createEmpty()); From 7e90fd45a7df20333f2444e8cf4d88ad8702ab74 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 07:44:46 +0100 Subject: [PATCH 024/115] Renamed ShortUrlInputFilter and added named constructors to it --- .../ShortUrl/GenerateShortUrlCommand.php | 22 ++++++------- module/Core/src/Entity/ShortUrl.php | 12 +++---- module/Core/src/Model/ShortUrlEdit.php | 22 ++++++------- module/Core/src/Model/ShortUrlMeta.php | 26 +++++++-------- ...nputFilter.php => ShortUrlInputFilter.php} | 23 ++++++++----- module/Core/test/Entity/ShortUrlTest.php | 4 +-- module/Core/test/Model/ShortUrlMetaTest.php | 32 +++++++++---------- .../Action/ShortUrl/CreateShortUrlAction.php | 4 +-- .../SingleStepCreateShortUrlAction.php | 8 ++--- .../DefaultShortCodesLengthMiddleware.php | 6 ++-- .../ShortUrl/OverrideDomainMiddleware.php | 6 ++-- .../DefaultShortCodesLengthMiddlewareTest.php | 8 ++--- .../ShortUrl/OverrideDomainMiddlewareTest.php | 18 +++++------ 13 files changed, 99 insertions(+), 92 deletions(-) rename module/Core/src/Validation/{ShortUrlMetaInputFilter.php => ShortUrlInputFilter.php} (86%) diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 68b013b6..a618af83 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -146,19 +146,19 @@ class GenerateShortUrlCommand extends BaseCommand try { $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::LONG_URL => $longUrl, - ShortUrlMetaInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'), - ShortUrlMetaInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'), - ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug, - ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, - ShortUrlMetaInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback( + ShortUrlInputFilter::LONG_URL => $longUrl, + ShortUrlInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'), + ShortUrlInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'), + ShortUrlInputFilter::CUSTOM_SLUG => $customSlug, + ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, + ShortUrlInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback( $input, 'find-if-exists', ), - ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'), - ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl, - ShortUrlMetaInputFilter::TAGS => $tags, + ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), + ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, + ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl, + ShortUrlInputFilter::TAGS => $tags, ])); $io->writeln([ diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 76df360e..2c86f9ec 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -51,7 +51,7 @@ class ShortUrl extends AbstractEntity public static function withLongUrl(string $longUrl): self { - return self::fromMeta(ShortUrlMeta::fromRawData([ShortUrlMetaInputFilter::LONG_URL => $longUrl])); + return self::fromMeta(ShortUrlMeta::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); } public static function fromMeta( @@ -83,12 +83,12 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null ): self { $meta = [ - ShortUrlMetaInputFilter::LONG_URL => $url->longUrl(), - ShortUrlMetaInputFilter::DOMAIN => $url->domain(), - ShortUrlMetaInputFilter::VALIDATE_URL => false, + ShortUrlInputFilter::LONG_URL => $url->longUrl(), + ShortUrlInputFilter::DOMAIN => $url->domain(), + ShortUrlInputFilter::VALIDATE_URL => false, ]; if ($importShortCode) { - $meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode(); + $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode(); } $instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver); diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index bb2aab33..824a5f60 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use function array_key_exists; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; @@ -44,21 +44,21 @@ final class ShortUrlEdit */ private function validateAndInit(array $data): void { - $inputFilter = new ShortUrlMetaInputFilter($data); + $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data); - $this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data); - $this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data); - $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data); + $this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data); + $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); + $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); + $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); - $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL); + $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); + $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); + $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL); } public function longUrl(): ?string diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 1286043b..13f36362 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; @@ -57,25 +57,25 @@ final class ShortUrlMeta */ private function validateAndInit(array $data): void { - $inputFilter = new ShortUrlMetaInputFilter($data, true); + $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); - $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); - $this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL); - $this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN); + $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); + $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); + $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); + $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); + $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL); + $this->domain = $inputFilter->getValue(ShortUrlInputFilter::DOMAIN); $this->shortCodeLength = getOptionalIntFromInputFilter( $inputFilter, - ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, + ShortUrlInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH; - $this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY); - $this->tags = $inputFilter->getValue(ShortUrlMetaInputFilter::TAGS); + $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); + $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); } public function getLongUrl(): string diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php similarity index 86% rename from module/Core/src/Validation/ShortUrlMetaInputFilter.php rename to module/Core/src/Validation/ShortUrlInputFilter.php index a57b402c..fa333b49 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; -class ShortUrlMetaInputFilter extends InputFilter +class ShortUrlInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -32,18 +32,25 @@ class ShortUrlMetaInputFilter extends InputFilter public const API_KEY = 'apiKey'; public const TAGS = 'tags'; - private bool $requireLongUrl; - - public function __construct(array $data, bool $requireLongUrl = false) + private function __construct(array $data, bool $requireLongUrl) { - $this->requireLongUrl = $requireLongUrl; - $this->initialize(); + $this->initialize($requireLongUrl); $this->setData($data); } - private function initialize(): void + public static function withRequiredLongUrl(array $data): self { - $longUrlInput = $this->createInput(self::LONG_URL, $this->requireLongUrl); + return new self($data, true); + } + + public static function withNonRequiredLongUrl(array $data): self + { + return new self($data, false); + } + + private function initialize(bool $requireLongUrl): void + { + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::OBJECT, Validator\NotEmpty::SPACE, diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 1f652274..3cd607da 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function Functional\map; @@ -75,7 +75,7 @@ class ShortUrlTest extends TestCase public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void { $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( - [ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], + [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], )); self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 848bd90b..2b57987b 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -8,7 +8,7 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use stdClass; class ShortUrlMetaTest extends TestCase @@ -27,37 +27,37 @@ class ShortUrlMetaTest extends TestCase public function provideInvalidData(): iterable { yield [[ - ShortUrlMetaInputFilter::VALID_SINCE => '', - ShortUrlMetaInputFilter::VALID_UNTIL => '', - ShortUrlMetaInputFilter::CUSTOM_SLUG => 'foobar', - ShortUrlMetaInputFilter::MAX_VISITS => 'invalid', + ShortUrlInputFilter::VALID_SINCE => '', + ShortUrlInputFilter::VALID_UNTIL => '', + ShortUrlInputFilter::CUSTOM_SLUG => 'foobar', + ShortUrlInputFilter::MAX_VISITS => 'invalid', ]]; yield [[ - ShortUrlMetaInputFilter::VALID_SINCE => '2017', - ShortUrlMetaInputFilter::MAX_VISITS => 5, + ShortUrlInputFilter::VALID_SINCE => '2017', + ShortUrlInputFilter::MAX_VISITS => 5, ]]; yield [[ - ShortUrlMetaInputFilter::VALID_SINCE => new stdClass(), - ShortUrlMetaInputFilter::VALID_UNTIL => 'foo', + ShortUrlInputFilter::VALID_SINCE => new stdClass(), + ShortUrlInputFilter::VALID_UNTIL => 'foo', ]]; yield [[ - ShortUrlMetaInputFilter::VALID_UNTIL => 500, - ShortUrlMetaInputFilter::DOMAIN => 4, + ShortUrlInputFilter::VALID_UNTIL => 500, + ShortUrlInputFilter::DOMAIN => 4, ]]; yield [[ - ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3, + ShortUrlInputFilter::SHORT_CODE_LENGTH => 3, ]]; yield [[ - ShortUrlMetaInputFilter::CUSTOM_SLUG => '/', + ShortUrlInputFilter::CUSTOM_SLUG => '/', ]]; yield [[ - ShortUrlMetaInputFilter::CUSTOM_SLUG => '', + ShortUrlInputFilter::CUSTOM_SLUG => '', ]]; yield [[ - ShortUrlMetaInputFilter::CUSTOM_SLUG => ' ', + ShortUrlInputFilter::CUSTOM_SLUG => ' ', ]]; yield [[ - ShortUrlMetaInputFilter::LONG_URL => [], + ShortUrlInputFilter::LONG_URL => [], ]]; } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 58a295ab..d8b873a6 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class CreateShortUrlAction extends AbstractCreateShortUrlAction @@ -21,7 +21,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction protected function buildShortUrlData(Request $request): ShortUrlMeta { $payload = (array) $request->getParsedBody(); - $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); + $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); return ShortUrlMeta::fromRawData($payload); } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 360e9d72..d8e39643 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction @@ -21,10 +21,10 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); return ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::LONG_URL => $longUrl, - ShortUrlMetaInputFilter::API_KEY => $apiKey, + ShortUrlInputFilter::LONG_URL => $longUrl, + ShortUrlInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain - ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN), + ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), ]); } } diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php index bcad748e..c1991de2 100644 --- a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -8,7 +8,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; class DefaultShortCodesLengthMiddleware implements MiddlewareInterface { @@ -22,8 +22,8 @@ class DefaultShortCodesLengthMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $body = $request->getParsedBody(); - if (! isset($body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH])) { - $body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength; + if (! isset($body[ShortUrlInputFilter::SHORT_CODE_LENGTH])) { + $body[ShortUrlInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength; } return $handler->handle($request->withParsedBody($body)); diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index 817570a8..c875a9ab 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -36,11 +36,11 @@ class OverrideDomainMiddleware implements MiddlewareInterface if ($requestMethod === RequestMethodInterface::METHOD_POST) { $payload = $request->getParsedBody(); - $payload[ShortUrlMetaInputFilter::DOMAIN] = $domain->getAuthority(); + $payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); return $handler->handle($request->withParsedBody($payload)); } - return $handler->handle($request->withAttribute(ShortUrlMetaInputFilter::DOMAIN, $domain->getAuthority())); + return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->getAuthority())); } } diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php index 918b0a5d..e10e9f73 100644 --- a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -13,7 +13,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware; class DefaultShortCodesLengthMiddlewareTest extends TestCase @@ -38,8 +38,8 @@ class DefaultShortCodesLengthMiddlewareTest extends TestCase $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); $handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) { $parsedBody = $req->getParsedBody(); - Assert::assertArrayHasKey(ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, $parsedBody); - Assert::assertEquals($expectedLength, $parsedBody[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH]); + Assert::assertArrayHasKey(ShortUrlInputFilter::SHORT_CODE_LENGTH, $parsedBody); + Assert::assertEquals($expectedLength, $parsedBody[ShortUrlInputFilter::SHORT_CODE_LENGTH]); return $req; }))->willReturn(new Response()); @@ -51,7 +51,7 @@ class DefaultShortCodesLengthMiddlewareTest extends TestCase public function provideBodies(): iterable { - yield 'value provided' => [[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 6], 6]; + yield 'value provided' => [[ShortUrlInputFilter::SHORT_CODE_LENGTH => 6], 6]; yield 'value not provided' => [[], 8]; } } diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index dcf4d7ce..9614f8c7 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -15,7 +15,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Middleware\ShortUrl\OverrideDomainMiddleware; @@ -82,21 +82,21 @@ class OverrideDomainMiddlewareTest extends TestCase public function provideBodies(): iterable { - yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlMetaInputFilter::DOMAIN => 'foo.com']]; + yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlInputFilter::DOMAIN => 'foo.com']]; yield 'other domain provided' => [ new Domain('bar.com'), - [ShortUrlMetaInputFilter::DOMAIN => 'foo.com'], - [ShortUrlMetaInputFilter::DOMAIN => 'bar.com'], + [ShortUrlInputFilter::DOMAIN => 'foo.com'], + [ShortUrlInputFilter::DOMAIN => 'bar.com'], ]; yield 'same domain provided' => [ new Domain('baz.com'), - [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], - [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], + [ShortUrlInputFilter::DOMAIN => 'baz.com'], + [ShortUrlInputFilter::DOMAIN => 'baz.com'], ]; yield 'more body params' => [ new Domain('doma.in'), - [ShortUrlMetaInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], - [ShortUrlMetaInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], + [ShortUrlInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], + [ShortUrlInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], ]; } @@ -113,7 +113,7 @@ class OverrideDomainMiddlewareTest extends TestCase $getDomain = $this->domainService->getDomain('123')->willReturn($domain); $handle = $this->handler->handle(Argument::that( function (ServerRequestInterface $req): bool { - Assert::assertEquals($req->getAttribute(ShortUrlMetaInputFilter::DOMAIN), 'something.com'); + Assert::assertEquals($req->getAttribute(ShortUrlInputFilter::DOMAIN), 'something.com'); return true; }, ))->willReturn(new Response()); From 82091c7951ab1762a38bc2ccf0aec4c9ed5a9327 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 10:53:18 +0100 Subject: [PATCH 025/115] Added logic to resolve tags during short URL creation through ShortUrlRelationResolver --- module/Core/src/Entity/ShortUrl.php | 4 +++- .../src/Importer/ImportedLinksProcessor.php | 5 ----- module/Core/src/Model/ShortUrlEdit.php | 17 +++++++++++++++++ module/Core/src/Service/ShortUrlService.php | 3 ++- .../src/Service/ShortUrlServiceInterface.php | 3 ++- module/Core/src/Service/UrlShortener.php | 4 ---- .../PersistenceShortUrlRelationResolver.php | 19 +++++++++++++++++++ .../ShortUrlRelationResolverInterface.php | 8 ++++++++ .../SimpleShortUrlRelationResolver.php | 14 ++++++++++++++ module/Core/src/Util/TagManagerTrait.php | 2 ++ .../Repository/DomainRepositoryTest.php | 7 +++++++ .../Core/test/Service/ShortUrlServiceTest.php | 4 ++-- .../Action/ShortUrl/EditShortUrlAction.php | 2 +- .../test-api/Action/CreateShortUrlTest.php | 18 ++++++++++++++---- .../ShortUrl/EditShortUrlActionTest.php | 2 +- 15 files changed, 92 insertions(+), 20 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 2c86f9ec..3f55c3f4 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -64,7 +64,7 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $meta->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->tags = new ArrayCollection(); + $instance->tags = $relationResolver->resolveTags($meta->getTags()); $instance->validSince = $meta->getValidSince(); $instance->validUntil = $meta->getValidUntil(); $instance->maxVisits = $meta->getMaxVisits(); @@ -85,6 +85,7 @@ class ShortUrl extends AbstractEntity $meta = [ ShortUrlInputFilter::LONG_URL => $url->longUrl(), ShortUrlInputFilter::DOMAIN => $url->domain(), + ShortUrlInputFilter::TAGS => $url->tags(), ShortUrlInputFilter::VALIDATE_URL => false, ]; if ($importShortCode) { @@ -129,6 +130,7 @@ class ShortUrl extends AbstractEntity /** * @param Collection|Tag[] $tags + * @deprecated */ public function setTags(Collection $tags): self { diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 8bac7395..2b5cde17 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; -use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Symfony\Component\Console\Style\StyleInterface; @@ -19,8 +18,6 @@ use function sprintf; class ImportedLinksProcessor implements ImportedLinksProcessorInterface { - use TagManagerTrait; - private EntityManagerInterface $em; private ShortUrlRelationResolverInterface $relationResolver; private ShortCodeHelperInterface $shortCodeHelper; @@ -59,8 +56,6 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface } $shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver); - $shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags())); - if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) { continue; } diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index 824a5f60..b8cb0e0c 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -23,6 +23,8 @@ final class ShortUrlEdit private ?Chronos $validUntil = null; private bool $maxVisitsPropWasProvided = false; private ?int $maxVisits = null; + private bool $tagsPropWasProvided = false; + private array $tags = []; private ?bool $validateUrl = null; private function __construct() @@ -53,12 +55,14 @@ final class ShortUrlEdit $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); + $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL); + $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); } public function longUrl(): ?string @@ -101,6 +105,19 @@ final class ShortUrlEdit return $this->maxVisitsPropWasProvided; } + /** + * @return string[] + */ + public function tags(): array + { + return $this->tags; + } + + public function hasTags(): bool + { + return $this->tagsPropWasProvided; + } + public function doValidateUrl(): ?bool { return $this->validateUrl; diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index aeb5233b..8b5a6362 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -53,6 +53,7 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @param string[] $tags + * @deprecated Use updateShortUrl instead * @throws ShortUrlNotFoundException */ public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl @@ -69,7 +70,7 @@ class ShortUrlService implements ShortUrlServiceInterface * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode( + public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, ?ApiKey $apiKey = null diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index b1cfbc2d..ac9f2095 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -22,6 +22,7 @@ interface ShortUrlServiceInterface /** * @param string[] $tags + * @deprecated Use updateShortUrl instead * @throws ShortUrlNotFoundException */ public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl; @@ -30,7 +31,7 @@ interface ShortUrlServiceInterface * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode( + public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, ?ApiKey $apiKey = null diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 08e64e60..f8125524 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -12,14 +12,11 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Throwable; class UrlShortener implements UrlShortenerInterface { - use TagManagerTrait; - private EntityManagerInterface $em; private UrlValidatorInterface $urlValidator; private ShortUrlRelationResolverInterface $relationResolver; @@ -54,7 +51,6 @@ class UrlShortener implements UrlShortenerInterface return $this->em->transactional(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); - $shortUrl->setTags($this->tagNamesToEntities($this->em, $meta->getTags())); $this->verifyShortCodeUniqueness($meta, $shortUrl); $this->em->persist($shortUrl); diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 0e3afa23..940a5a9f 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -4,8 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; +use Doctrine\Common\Collections; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Entity\Tag; + +use function Functional\map; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -26,4 +31,18 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]); return $existingDomain ?? new Domain($domain); } + + /** + * @param string[] $tags + * @return Collection|Tag[] + */ + public function resolveTags(array $tags): Collections\Collection + { + return new Collections\ArrayCollection(map($tags, function (string $tagName): Tag { + $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?? new Tag($tagName); + $this->em->persist($tag); + + return $tag; + })); + } } diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index bc576dbd..2d46a17b 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -4,9 +4,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; +use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Entity\Tag; interface ShortUrlRelationResolverInterface { public function resolveDomain(?string $domain): ?Domain; + + /** + * @param string[] $tags + * @return Collection|Tag[] + */ + public function resolveTags(array $tags): Collection; } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 4e4620f5..2cda44df 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -4,7 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; +use Doctrine\Common\Collections; +use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Entity\Tag; + +use function Functional\map; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -12,4 +17,13 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac { return $domain !== null ? new Domain($domain) : null; } + + /** + * @param string[] $tags + * @return Collection|Tag[] + */ + public function resolveTags(array $tags): Collections\Collection + { + return new Collections\ArrayCollection(map($tags, fn (string $tag) => new Tag($tag))); + } } diff --git a/module/Core/src/Util/TagManagerTrait.php b/module/Core/src/Util/TagManagerTrait.php index 27fb22b5..c6258f91 100644 --- a/module/Core/src/Util/TagManagerTrait.php +++ b/module/Core/src/Util/TagManagerTrait.php @@ -13,10 +13,12 @@ use function str_replace; use function strtolower; use function trim; +/** @deprecated */ trait TagManagerTrait { /** * @param string[] $tags + * @deprecated * @return Collections\Collection|Tag[] */ private function tagNamesToEntities(EntityManagerInterface $em, array $tags): Collections\Collection diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 3231eec4..49265eb0 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Domain\Repository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -102,6 +104,11 @@ class DomainRepositoryTest extends DatabaseTestCase { return $this->domain; } + + public function resolveTags(array $tags): Collection + { + return new ArrayCollection(); + } }, ); } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index d056b91b..d83d5e4f 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -100,7 +100,7 @@ class ShortUrlServiceTest extends TestCase * @test * @dataProvider provideShortUrlEdits */ - public function updateMetadataByShortCodeUpdatesProvidedData( + public function updateShortUrlUpdatesProvidedData( int $expectedValidateCalls, ShortUrlEdit $shortUrlEdit, ?ApiKey $apiKey @@ -114,7 +114,7 @@ class ShortUrlServiceTest extends TestCase )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); - $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); + $result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 32d95b2d..d7d283c7 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -31,7 +31,7 @@ class EditShortUrlAction extends AbstractRestAction $identifier = ShortUrlIdentifier::fromApiRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit, $apiKey); + $this->shortUrlService->updateShortUrl($identifier, $shortUrlEdit, $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 54ea0218..868ad142 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -60,13 +60,23 @@ class CreateShortUrlTest extends ApiTestCase } } - /** @test */ - public function createsNewShortUrlWithTags(): void + /** + * @test + * @dataProvider provideTags + */ + public function createsNewShortUrlWithTags(array $providedTags, array $expectedTags): void { - [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => ['foo', 'bar', 'baz']]); + [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => $providedTags]); self::assertEquals(self::STATUS_OK, $statusCode); - self::assertEquals(['foo', 'bar', 'baz'], $tags); + self::assertEquals($expectedTags, $tags); + } + + public function provideTags(): iterable + { + yield 'simple tags' => [$simpleTags = ['foo', 'bar', 'baz'], $simpleTags]; + yield 'tags with spaces' => [['fo o', ' bar', 'b az'], ['fo-o', 'bar', 'b-az']]; + yield 'tags with special chars' => [['UUU', 'Aäa'], ['uuu', 'aäa']]; } /** diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index ad482098..7a7d19ee 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -48,7 +48,7 @@ class EditShortUrlActionTest extends TestCase ->withParsedBody([ 'maxVisits' => 5, ]); - $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willReturn( + $updateMeta = $this->shortUrlService->updateShortUrl(Argument::cetera())->willReturn( ShortUrl::createEmpty(), ); From 09f25d78b783fd29fae3859b3d983c9b4565c461 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 11:01:38 +0100 Subject: [PATCH 026/115] Refactored API tests fixtures to avoid using deprecated methods --- .../test-api/Fixtures/ShortUrlsFixture.php | 19 +++++++++---- module/Rest/test-api/Fixtures/TagsFixture.php | 28 ++----------------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 390a2144..93defe90 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -18,18 +18,23 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf { public function getDependencies(): array { - return [ApiKeyFixture::class]; + return [ApiKeyFixture::class, TagsFixture::class]; } public function load(ObjectManager $manager): void { + $relationResolver = new PersistenceShortUrlRelationResolver($manager); + /** @var ApiKey $authorApiKey */ $authorApiKey = $this->getReference('author_api_key'); $abcShortUrl = $this->setShortUrlDate( - ShortUrl::fromMeta(ShortUrlMeta::fromRawData( - ['customSlug' => 'abc123', 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io'], - )), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'customSlug' => 'abc123', + 'apiKey' => $authorApiKey, + 'longUrl' => 'https://shlink.io', + 'tags' => ['foo'], + ]), $relationResolver), '2018-05-01', ); $manager->persist($abcShortUrl); @@ -40,7 +45,8 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - ])), '2019-01-01 00:00:10'); + 'tags' => ['foo', 'bar'], + ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); $customShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData( @@ -61,7 +67,8 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'customSlug' => 'ghi789', 'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-' . 'source-software-projects/', - ]), new PersistenceShortUrlRelationResolver($manager)), '2019-01-01 00:00:30'); + 'tags' => ['foo'], + ]), $relationResolver), '2019-01-01 00:00:30'); $manager->persist($withDomainDuplicatingShortCode); $withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData( diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index bf16104e..a28357a1 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -4,40 +4,18 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Fixtures; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\DataFixtures\AbstractFixture; -use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; -class TagsFixture extends AbstractFixture implements DependentFixtureInterface +class TagsFixture extends AbstractFixture { - public function getDependencies(): array - { - return [ShortUrlsFixture::class]; - } - public function load(ObjectManager $manager): void { - $fooTag = new Tag('foo'); - $manager->persist($fooTag); - $barTag = new Tag('bar'); - $manager->persist($barTag); + $manager->persist(new Tag('foo')); + $manager->persist(new Tag('bar')); $manager->persist(new Tag('baz')); - /** @var ShortUrl $abcShortUrl */ - $abcShortUrl = $this->getReference('abc123_short_url'); - $abcShortUrl->setTags(new ArrayCollection([$fooTag])); - - /** @var ShortUrl $defShortUrl */ - $defShortUrl = $this->getReference('def456_short_url'); - $defShortUrl->setTags(new ArrayCollection([$fooTag, $barTag])); - - /** @var ShortUrl $exampleShortUrl */ - $exampleShortUrl = $this->getReference('example_short_url'); - $exampleShortUrl->setTags(new ArrayCollection([$fooTag])); - $manager->flush(); } } From 1cd6fdeedef31bd8af81a4feb64a30a7f53cb18e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 11:09:00 +0100 Subject: [PATCH 027/115] Centralized logic to normalize tag names and removed references to deprecated setTags method in unit tests --- module/Core/src/Util/TagManagerTrait.php | 16 ++++++---------- module/Core/test/Service/UrlShortenerTest.php | 5 +++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Util/TagManagerTrait.php b/module/Core/src/Util/TagManagerTrait.php index c6258f91..9fac8700 100644 --- a/module/Core/src/Util/TagManagerTrait.php +++ b/module/Core/src/Util/TagManagerTrait.php @@ -7,11 +7,9 @@ namespace Shlinkio\Shlink\Core\Util; use Doctrine\Common\Collections; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use function Functional\map; -use function str_replace; -use function strtolower; -use function trim; /** @deprecated */ trait TagManagerTrait @@ -23,8 +21,11 @@ trait TagManagerTrait */ private function tagNamesToEntities(EntityManagerInterface $em, array $tags): Collections\Collection { - $entities = map($tags, function (string $tagName) use ($em) { - $tagName = $this->normalizeTagName($tagName); + $normalizedTags = ShortUrlInputFilter::withNonRequiredLongUrl([ + ShortUrlInputFilter::TAGS => $tags, + ])->getValue(ShortUrlInputFilter::TAGS); + + $entities = map($normalizedTags, function (string $tagName) use ($em) { $tag = $em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?? new Tag($tagName); $em->persist($tag); @@ -33,9 +34,4 @@ trait TagManagerTrait return new Collections\ArrayCollection($entities); } - - private function normalizeTagName(string $tagName): string - { - return str_replace(' ', '-', strtolower(trim($tagName))); - } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index caa224b6..ed038141 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -119,7 +119,7 @@ class UrlShortenerTest extends TestCase ), ShortUrl::withLongUrl($url)]; yield [ ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url, 'tags' => ['foo', 'bar']]), - ShortUrl::withLongUrl($url)->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])), + ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => $url, 'tags' => ['foo', 'bar']])), ]; yield [ ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]), @@ -157,7 +157,8 @@ class UrlShortenerTest extends TestCase 'validUntil' => Chronos::parse('2017-01-01'), 'maxVisits' => 4, 'longUrl' => $url, - ]))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])), + 'tags' => ['foo', 'bar', 'baz'], + ])), ]; } } From c58fa586e1daccce516e8409d1f458e3df6fe5e9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 11:50:45 +0100 Subject: [PATCH 028/115] Removed use of deprecated methods in DB tests --- module/Core/src/Entity/ShortUrl.php | 2 +- .../Repository/ShortUrlRepositoryTest.php | 58 ++++++++------- .../test-db/Repository/TagRepositoryTest.php | 71 +++++++++---------- .../Repository/VisitRepositoryTest.php | 60 +++++++--------- .../Core/test/Service/ShortUrlServiceTest.php | 5 +- module/Core/test/Service/UrlShortenerTest.php | 2 - 6 files changed, 89 insertions(+), 109 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 3f55c3f4..975028f8 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -130,7 +130,7 @@ class ShortUrl extends AbstractEntity /** * @param Collection|Tag[] $tags - * @deprecated + * @deprecated Use ShortUrl::update to set the tags on this ShortUrl */ public function setTags(Collection $tags): self { diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 6802d093..cf38d5a6 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -10,14 +10,12 @@ use ReflectionObject; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; -use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -27,13 +25,13 @@ use function count; class ShortUrlRepositoryTest extends DatabaseTestCase { - use TagManagerTrait; - private ShortUrlRepository $repo; + private PersistenceShortUrlRelationResolver $relationResolver; public function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(ShortUrl::class); + $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } /** @test */ @@ -90,11 +88,10 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findListProperlyFiltersResult(): void { - $tag = new Tag('bar'); - $this->getEntityManager()->persist($tag); - - $foo = ShortUrl::withLongUrl('foo'); - $foo->setTags(new ArrayCollection([$tag])); + $foo = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), + $this->relationResolver, + ); $this->getEntityManager()->persist($foo); $bar = ShortUrl::withLongUrl('bar'); @@ -235,8 +232,10 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $start = Chronos::parse('2020-03-05 20:18:30'); $end = Chronos::parse('2021-03-05 20:18:30'); - $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo'])); - $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); + $shortUrl = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), + $this->relationResolver, + ); $this->getEntityManager()->persist($shortUrl); $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])); @@ -300,28 +299,24 @@ class ShortUrlRepositoryTest extends DatabaseTestCase public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void { $start = Chronos::parse('2020-03-05 20:18:30'); - $meta = ShortUrlMeta::fromRawData(['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo']); $tags = ['foo', 'bar']; - $tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags); - $metaWithTags = ShortUrlMeta::fromRawData( + $meta = ShortUrlMeta::fromRawData( ['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo', 'tags' => $tags], ); - $shortUrl1 = ShortUrl::fromMeta($meta); - $shortUrl1->setTags($tagEntities); + $shortUrl1 = ShortUrl::fromMeta($meta, $this->relationResolver); $this->getEntityManager()->persist($shortUrl1); - - $shortUrl2 = ShortUrl::fromMeta($meta); - $shortUrl2->setTags($tagEntities); - $this->getEntityManager()->persist($shortUrl2); - - $shortUrl3 = ShortUrl::fromMeta($meta); - $shortUrl3->setTags($tagEntities); - $this->getEntityManager()->persist($shortUrl3); - $this->getEntityManager()->flush(); - $result = $this->repo->findOneMatching($metaWithTags); + $shortUrl2 = ShortUrl::fromMeta($meta, $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $this->getEntityManager()->flush(); + + $shortUrl3 = ShortUrl::fromMeta($meta, $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $this->getEntityManager()->flush(); + + $result = $this->repo->findOneMatching($meta); self::assertSame($shortUrl1, $result); self::assertNotSame($shortUrl2, $result); @@ -349,10 +344,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain)); $this->getEntityManager()->persist($rightDomainApiKey); - $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData( - ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority(), 'longUrl' => 'foo'], - ), new PersistenceShortUrlRelationResolver($this->getEntityManager())); - $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'apiKey' => $apiKey, + 'domain' => $rightDomain->getAuthority(), + 'longUrl' => 'foo', + 'tags' => ['foo', 'bar'], + ]), $this->relationResolver); $this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 58f146f3..0a91775b 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; -use Doctrine\Common\Collections\ArrayCollection; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; @@ -22,10 +21,12 @@ use function array_chunk; class TagRepositoryTest extends DatabaseTestCase { private TagRepository $repo; + private PersistenceShortUrlRelationResolver $relationResolver; protected function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(Tag::class); + $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } /** @test */ @@ -52,49 +53,44 @@ class TagRepositoryTest extends DatabaseTestCase public function properTagsInfoIsReturned(): void { $names = ['foo', 'bar', 'baz', 'another']; - $tags = []; foreach ($names as $name) { - $tag = new Tag($name); - $tags[] = $tag; - $this->getEntityManager()->persist($tag); + $this->getEntityManager()->persist(new Tag($name)); } + $this->getEntityManager()->flush(); - [$firstUrlTags] = array_chunk($tags, 3); - $secondUrlTags = [$tags[0]]; + [$firstUrlTags] = array_chunk($names, 3); + $secondUrlTags = [$names[0]]; + $metaWithTags = fn (array $tags) => ShortUrlMeta::fromRawData(['longUrl' => '', 'tags' => $tags]); - $shortUrl = ShortUrl::createEmpty(); - $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags), $this->relationResolver); $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 = ShortUrl::createEmpty(); - $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); + $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->flush(); $result = $this->repo->findTagsWithInfo(); self::assertCount(4, $result); - self::assertEquals( - ['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0], - $result[0]->jsonSerialize(), - ); - self::assertEquals( - ['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3], - $result[1]->jsonSerialize(), - ); - self::assertEquals( - ['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3], - $result[2]->jsonSerialize(), - ); - self::assertEquals( - ['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4], - $result[3]->jsonSerialize(), - ); + self::assertEquals(0, $result[0]->shortUrlsCount()); + self::assertEquals(0, $result[0]->visitsCount()); + self::assertEquals($names[3], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($names[1], $result[1]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($names[2], $result[2]->tag()->__toString()); + + self::assertEquals(2, $result[3]->shortUrlsCount()); + self::assertEquals(4, $result[3]->visitsCount()); + self::assertEquals($names[0], $result[3]->tag()->__toString()); } /** @test */ @@ -110,24 +106,23 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($domainApiKey); $names = ['foo', 'bar', 'baz', 'another']; - $tags = []; foreach ($names as $name) { - $tag = new Tag($name); - $tags[] = $tag; - $this->getEntityManager()->persist($tag); + $this->getEntityManager()->persist(new Tag($name)); } + $this->getEntityManager()->flush(); - [$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3); + [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); - $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => ''])); - $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $shortUrl = ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]), + $this->relationResolver, + ); $this->getEntityManager()->persist($shortUrl); $shortUrl2 = ShortUrl::fromMeta( - ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'longUrl' => '']), - new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags]), + $this->relationResolver, ); - $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); $this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index ebdc2116..740edff5 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -5,11 +5,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; use Cake\Chronos\Chronos; -use Doctrine\Common\Collections\ArrayCollection; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -28,10 +26,12 @@ use function sprintf; class VisitRepositoryTest extends DatabaseTestCase { private VisitRepository $repo; + private PersistenceShortUrlRelationResolver $relationResolver; protected function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(Visit::class); + $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } /** @@ -126,58 +126,45 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findVisitsByTagReturnsProperData(): void { - $foo = new Tag('foo'); - $this->getEntityManager()->persist($foo); + $foo = 'foo'; /** @var ShortUrl $shortUrl */ - [,, $shortUrl] = $this->createShortUrlsAndVisits(false); - /** @var ShortUrl $shortUrl2 */ - [,, $shortUrl2] = $this->createShortUrlsAndVisits(false); - /** @var ShortUrl $shortUrl3 */ - [,, $shortUrl3] = $this->createShortUrlsAndVisits(false); + $this->createShortUrlsAndVisits(false, [$foo]); + $this->getEntityManager()->flush(); - $shortUrl->setTags(new ArrayCollection([$foo])); - $shortUrl2->setTags(new ArrayCollection([$foo])); - $shortUrl3->setTags(new ArrayCollection([$foo])); + $this->createShortUrlsAndVisits(false, [$foo]); + $this->getEntityManager()->flush(); + $this->createShortUrlsAndVisits(false, [$foo]); $this->getEntityManager()->flush(); self::assertCount(0, $this->repo->findVisitsByTag('invalid')); - self::assertCount(18, $this->repo->findVisitsByTag((string) $foo)); - self::assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange( + self::assertCount(18, $this->repo->findVisitsByTag($foo)); + self::assertCount(6, $this->repo->findVisitsByTag($foo, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - self::assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange( - Chronos::parse('2016-01-03'), - ))); + self::assertCount(12, $this->repo->findVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03')))); } /** @test */ public function countVisitsByTagReturnsProperData(): void { - $foo = new Tag('foo'); - $this->getEntityManager()->persist($foo); + $foo = 'foo'; - /** @var ShortUrl $shortUrl */ - [,, $shortUrl] = $this->createShortUrlsAndVisits(false); - /** @var ShortUrl $shortUrl2 */ - [,, $shortUrl2] = $this->createShortUrlsAndVisits(false); - - $shortUrl->setTags(new ArrayCollection([$foo])); - $shortUrl2->setTags(new ArrayCollection([$foo])); + $this->createShortUrlsAndVisits(false, [$foo]); + $this->getEntityManager()->flush(); + $this->createShortUrlsAndVisits(false, [$foo]); $this->getEntityManager()->flush(); self::assertEquals(0, $this->repo->countVisitsByTag('invalid')); - self::assertEquals(12, $this->repo->countVisitsByTag((string) $foo)); - self::assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange( + self::assertEquals(12, $this->repo->countVisitsByTag($foo)); + self::assertEquals(4, $this->repo->countVisitsByTag($foo, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - self::assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange( - Chronos::parse('2016-01-03'), - ))); + self::assertEquals(8, $this->repo->countVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03')))); } /** @test */ @@ -192,7 +179,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::fromMeta( ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), - new PersistenceShortUrlRelationResolver($this->getEntityManager()), + $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 4); @@ -205,7 +192,7 @@ class VisitRepositoryTest extends DatabaseTestCase $shortUrl3 = ShortUrl::fromMeta( ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), - new PersistenceShortUrlRelationResolver($this->getEntityManager()), + $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl3); $this->createVisitsForShortUrl($shortUrl3, 7); @@ -221,9 +208,12 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey)); } - private function createShortUrlsAndVisits(bool $withDomain = true): array + private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => '', + 'tags' => $tags, + ]), $this->relationResolver); $domain = 'example.com'; $shortCode = $shortUrl->getShortCode(); $this->getEntityManager()->persist($shortUrl); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index d83d5e4f..be0389e6 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -81,11 +81,10 @@ class ShortUrlServiceTest extends TestCase */ public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void { - $shortUrl = $this->prophesize(ShortUrl::class); - $shortUrl->setTags(Argument::any())->shouldBeCalledOnce(); + $shortUrl = ShortUrl::createEmpty(); $shortCode = 'abc123'; $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey) - ->willReturn($shortUrl->reveal()) + ->willReturn($shortUrl) ->shouldBeCalledOnce(); $tagRepo = $this->prophesize(EntityRepository::class); diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index ed038141..a9ba783f 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -5,14 +5,12 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Service; use Cake\Chronos\Chronos; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; From 977058d219fc5d5a73e1946c6cf3a6f3cf5a8eaf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 12:12:21 +0100 Subject: [PATCH 029/115] Updated short URL edition so that it supports editing tags --- module/Core/config/dependencies.config.php | 7 ++++++- module/Core/src/Entity/ShortUrl.php | 10 ++++++++-- module/Core/src/Service/ShortUrlService.php | 8 ++++++-- .../Core/test/Service/ShortUrlServiceTest.php | 2 ++ .../test-api/Action/EditShortUrlTagsTest.php | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a843a0a2..0eaa7a8e 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -77,7 +77,12 @@ return [ EventDispatcherInterface::class, 'config.url_shortener.anonymize_remote_addr', ], - Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class], + Service\ShortUrlService::class => [ + 'em', + Service\ShortUrl\ShortUrlResolver::class, + Util\UrlValidator::class, + ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, + ], Visit\VisitLocator::class => ['em'], Visit\VisitsStatsHelper::class => ['em'], Tag\TagService::class => ['em'], diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 975028f8..bf01e7a5 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -138,8 +138,10 @@ class ShortUrl extends AbstractEntity return $this; } - public function update(ShortUrlEdit $shortUrlEdit): void - { + public function update( + ShortUrlEdit $shortUrlEdit, + ?ShortUrlRelationResolverInterface $relationResolver = null + ): void { if ($shortUrlEdit->hasValidSince()) { $this->validSince = $shortUrlEdit->validSince(); } @@ -152,6 +154,10 @@ class ShortUrl extends AbstractEntity if ($shortUrlEdit->hasLongUrl()) { $this->longUrl = $shortUrlEdit->longUrl(); } + if ($shortUrlEdit->hasTags()) { + $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); + $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); + } } /** diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 8b5a6362..44de59a1 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -26,15 +27,18 @@ class ShortUrlService implements ShortUrlServiceInterface private ORM\EntityManagerInterface $em; private ShortUrlResolverInterface $urlResolver; private UrlValidatorInterface $urlValidator; + private ShortUrlRelationResolverInterface $relationResolver; public function __construct( ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver, - UrlValidatorInterface $urlValidator + UrlValidatorInterface $urlValidator, + ShortUrlRelationResolverInterface $relationResolver ) { $this->em = $em; $this->urlResolver = $urlResolver; $this->urlValidator = $urlValidator; + $this->relationResolver = $relationResolver; } /** @@ -80,7 +84,7 @@ class ShortUrlService implements ShortUrlServiceInterface } $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); - $shortUrl->update($shortUrlEdit); + $shortUrl->update($shortUrlEdit, $this->relationResolver); $this->em->flush(); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index be0389e6..5d873ee9 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -19,6 +19,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrlService; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; @@ -48,6 +49,7 @@ class ShortUrlServiceTest extends TestCase $this->em->reveal(), $this->urlResolver->reveal(), $this->urlValidator->reveal(), + new SimpleShortUrlRelationResolver(), ); } diff --git a/module/Rest/test-api/Action/EditShortUrlTagsTest.php b/module/Rest/test-api/Action/EditShortUrlTagsTest.php index f016882b..18f6f3b0 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsTest.php @@ -52,6 +52,25 @@ class EditShortUrlTagsTest extends ApiTestCase self::assertEquals($domain, $payload['domain'] ?? null); } + /** @test */ + public function allowsEditingTagsWithTwoEndpoints(): void + { + $getUrlTagsFromApi = fn () => $this->getJsonResponsePayload( + $this->callApiWithKey(self::METHOD_GET, '/short-urls/abc123'), + )['tags'] ?? null; + self::assertEquals(['foo'], $getUrlTagsFromApi()); + + $this->callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => [ + 'tags' => ['a', 'e'], + ]]); + self::assertEquals(['a', 'e'], $getUrlTagsFromApi()); + + $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/abc123', [RequestOptions::JSON => [ + 'tags' => ['i', 'o', 'u'], + ]]); + self::assertEquals(['i', 'o', 'u'], $getUrlTagsFromApi()); + } + /** @test */ public function tagsAreSetOnProperShortUrlBasedOnProvidedDomain(): void { From cdfd14e63f84d8c3d13a6f829c48e1e7393520ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 12:24:26 +0100 Subject: [PATCH 030/115] Deprecated action and endpoint to edit short URL tags --- .../paths/v1_short-urls_{shortCode}.json | 7 ++++++ .../paths/v1_short-urls_{shortCode}_tags.json | 3 ++- module/Core/src/Service/ShortUrlService.php | 18 --------------- .../src/Service/ShortUrlServiceInterface.php | 7 ------ .../Core/test/Service/ShortUrlServiceTest.php | 22 ------------------- .../ShortUrl/EditShortUrlTagsAction.php | 7 +++++- .../ShortUrl/EditShortUrlTagsActionTest.php | 5 +++-- 7 files changed, 18 insertions(+), 51 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index c7e7dc8a..546e1920 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -131,6 +131,13 @@ "validateUrl": { "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The list of tags to set to the short URL." } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json index fd497380..6ea642b0 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json @@ -1,11 +1,12 @@ { "put": { + "deprecated": true, "operationId": "editShortUrlTags", "tags": [ "Short URLs" ], "summary": "Edit tags on short URL", - "description": "Edit the tags on URL identified by provided short code.", + "description": "Edit the tags on URL identified by provided short code.
This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.", "parameters": [ { "$ref": "../parameters/version.json" diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 44de59a1..70606219 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -16,14 +16,11 @@ use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { - use TagManagerTrait; - private ORM\EntityManagerInterface $em; private ShortUrlResolverInterface $urlResolver; private UrlValidatorInterface $urlValidator; @@ -55,21 +52,6 @@ class ShortUrlService implements ShortUrlServiceInterface return $paginator; } - /** - * @param string[] $tags - * @deprecated Use updateShortUrl instead - * @throws ShortUrlNotFoundException - */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl - { - $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); - $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); - - $this->em->flush(); - - return $shortUrl; - } - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index ac9f2095..3884b55e 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -20,13 +20,6 @@ interface ShortUrlServiceInterface */ public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; - /** - * @param string[] $tags - * @deprecated Use updateShortUrl instead - * @throws ShortUrlNotFoundException - */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl; - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 5d873ee9..178561f0 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -6,13 +6,11 @@ namespace ShlinkioTest\Shlink\Core\Service; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; @@ -77,26 +75,6 @@ class ShortUrlServiceTest extends TestCase self::assertCount(4, $paginator->getCurrentPageResults()); } - /** - * @test - * @dataProvider provideAdminApiKeys - */ - public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void - { - $shortUrl = ShortUrl::createEmpty(); - $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey) - ->willReturn($shortUrl) - ->shouldBeCalledOnce(); - - $tagRepo = $this->prophesize(EntityRepository::class); - $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce(); - $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce(); - $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); - - $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey); - } - /** * @test * @dataProvider provideShortUrlEdits diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 7d115765..d114049c 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -8,11 +8,14 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +/** @deprecated */ class EditShortUrlTagsAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}/tags'; @@ -38,7 +41,9 @@ class EditShortUrlTagsAction extends AbstractRestAction $identifier = ShortUrlIdentifier::fromApiRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags, $apiKey); + $shortUrl = $this->shortUrlService->updateShortUrl($identifier, ShortUrlEdit::fromRawData([ + ShortUrlInputFilter::TAGS => $tags, + ]), $apiKey); return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index 60d1d093..a345046a 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; @@ -41,9 +42,9 @@ class EditShortUrlTagsActionTest extends TestCase public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void { $shortCode = 'abc123'; - $this->shortUrlService->setTagsByShortCode( + $this->shortUrlService->updateShortUrl( new ShortUrlIdentifier($shortCode), - [], + Argument::type(ShortUrlEdit::class), Argument::type(ApiKey::class), )->willReturn(ShortUrl::createEmpty()) ->shouldBeCalledOnce(); From 6b0f6e4541e8297ba66ac3d3a32895f5fd8ed821 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 12:27:35 +0100 Subject: [PATCH 031/115] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index baf1f824..12517bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0 +* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`). + + The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead. + ### Removed * *Nothing* From ef12e90ae7d681f4a7c99f5677194a608a0647e7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 13:05:21 +0100 Subject: [PATCH 032/115] Removed non-used deprecated method and added missing tests --- module/Core/src/Entity/ShortUrl.php | 10 ----- .../PersistenceShortUrlRelationResolver.php | 9 +++- ...ersistenceShortUrlRelationResolverTest.php | 41 +++++++++++++++++++ .../SimpleShortUrlRelationResolverTest.php | 12 ++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index bf01e7a5..2919be17 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -128,16 +128,6 @@ class ShortUrl extends AbstractEntity return $this->tags; } - /** - * @param Collection|Tag[] $tags - * @deprecated Use ShortUrl::update to set the tags on this ShortUrl - */ - public function setTags(Collection $tags): self - { - $this->tags = $tags; - return $this; - } - public function update( ShortUrlEdit $shortUrlEdit, ?ShortUrlRelationResolverInterface $relationResolver = null diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 940a5a9f..fd0428bf 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -38,8 +38,13 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt */ public function resolveTags(array $tags): Collections\Collection { - return new Collections\ArrayCollection(map($tags, function (string $tagName): Tag { - $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?? new Tag($tagName); + if (empty($tags)) { + return new Collections\ArrayCollection(); + } + + $repo = $this->em->getRepository(Tag::class); + return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag { + $tag = $repo->findOneBy(['name' => $tagName]) ?? new Tag($tagName); $this->em->persist($tag); return $tag; diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 9cea7883..463ee1ef 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -7,9 +7,12 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ObjectRepository; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; class PersistenceShortUrlRelationResolverTest extends TestCase @@ -62,4 +65,42 @@ class PersistenceShortUrlRelationResolverTest extends TestCase yield 'not found domain' => [null, $authority]; yield 'found domain' => [new Domain($authority), $authority]; } + + /** @test */ + public function findsAndPersistsTagsWrappedIntoCollection(): void + { + $tags = ['foo', 'bar', 'baz']; + + $tagRepo = $this->prophesize(TagRepositoryInterface::class); + $findTag = $tagRepo->findOneBy(Argument::type('array'))->will(function (array $args): ?Tag { + ['name' => $name] = $args[0]; + return $name === 'foo' ? new Tag($name) : null; + }); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); + $persist = $this->em->persist(Argument::type(Tag::class)); + + $result = $this->resolver->resolveTags($tags); + + self::assertCount(3, $result); + self::assertEquals([new Tag('foo'), new Tag('bar'), new Tag('baz')], $result->toArray()); + $findTag->shouldHaveBeenCalledTimes(3); + $getRepo->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledTimes(3); + } + + /** @test */ + public function returnsEmptyCollectionWhenProvidingEmptyListOfTags(): void + { + $tagRepo = $this->prophesize(TagRepositoryInterface::class); + $findTag = $tagRepo->findOneBy(Argument::type('array'))->willReturn(null); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); + $persist = $this->em->persist(Argument::type(Tag::class)); + + $result = $this->resolver->resolveTags([]); + + self::assertEmpty($result); + $findTag->shouldNotHaveBeenCalled(); + $getRepo->shouldNotHaveBeenCalled(); + $persist->shouldNotHaveBeenCalled(); + } } diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index 84d838b9..483cb67a 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; class SimpleShortUrlRelationResolverTest extends TestCase @@ -38,4 +39,15 @@ class SimpleShortUrlRelationResolverTest extends TestCase yield 'empty domain' => [null]; yield 'non-empty domain' => ['domain.com']; } + + /** @test */ + public function tagsAreWrappedInEntityCollection(): void + { + $tags = ['foo', 'bar', 'baz']; + + $result = $this->resolver->resolveTags($tags); + + self::assertCount(3, $result); + self::assertEquals([new Tag('foo'), new Tag('bar'), new Tag('baz')], $result->toArray()); + } } From 85bc5ce595e541443efe70401cf9d9b74a34a8c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 13:12:56 +0100 Subject: [PATCH 033/115] Moved transformer to constructor in some actions, to avoid creating it over and over --- .../src/Action/ShortUrl/AbstractCreateShortUrlAction.php | 8 +++----- module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php | 8 +++----- module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php | 7 +++---- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index cfec8cac..a7278457 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -16,22 +16,20 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { private UrlShortenerInterface $urlShortener; - private array $domainConfig; + private ShortUrlDataTransformer $transformer; public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig) { $this->urlShortener = $urlShortener; - $this->domainConfig = $domainConfig; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } public function handle(Request $request): Response { $shortUrlMeta = $this->buildShortUrlData($request); - $shortUrl = $this->urlShortener->shorten($shortUrlMeta); - $transformer = new ShortUrlDataTransformer($this->domainConfig); - return new JsonResponse($transformer->transform($shortUrl)); + return new JsonResponse($this->transformer->transform($shortUrl)); } /** diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 8da502cf..cd1bb4af 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -22,12 +22,12 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private ShortUrlServiceInterface $shortUrlService; - private array $domainConfig; + private ShortUrlDataTransformer $transformer; public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) { $this->shortUrlService = $shortUrlService; - $this->domainConfig = $domainConfig; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } public function handle(Request $request): Response @@ -36,8 +36,6 @@ class ListShortUrlsAction extends AbstractRestAction ShortUrlsParams::fromRawData($request->getQueryParams()), AuthenticationMiddleware::apiKeyFromRequest($request), ); - return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( - $this->domainConfig, - ))]); + return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, $this->transformer)]); } } diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 99e58fee..fafd15df 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -19,22 +19,21 @@ class ResolveShortUrlAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private ShortUrlResolverInterface $urlResolver; - private array $domainConfig; + private ShortUrlDataTransformer $transformer; public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig) { $this->urlResolver = $urlResolver; - $this->domainConfig = $domainConfig; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } public function handle(Request $request): Response { - $transformer = new ShortUrlDataTransformer($this->domainConfig); $url = $this->urlResolver->resolveShortUrl( ShortUrlIdentifier::fromApiRequest($request), AuthenticationMiddleware::apiKeyFromRequest($request), ); - return new JsonResponse($transformer->transform($url)); + return new JsonResponse($this->transformer->transform($url)); } } From c61e1e1c0eac3e6cf60dfd49cf60cbb9675e7d75 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 31 Jan 2021 13:21:23 +0100 Subject: [PATCH 034/115] Updated EditShortUrlAction so that it returns the parsed short URL instead of an empty response --- .../paths/v1_short-urls_{shortCode}.json | 29 +++++++++++++++++-- module/Rest/config/dependencies.config.php | 2 +- .../Action/ShortUrl/EditShortUrlAction.php | 12 +++++--- .../Rest/test-api/Action/EditShortUrlTest.php | 8 ++--- .../test-api/Action/ResolveShortUrlTest.php | 2 +- .../ShortUrl/EditShortUrlActionTest.php | 4 +-- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 546e1920..6cfa3c97 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -150,8 +150,33 @@ } ], "responses": { - "204": { - "description": "The short code has been properly updated." + "200": { + "description": "The short URL has been properly updated.", + "content": { + "application/json": { + "schema": { + "$ref": "../definitions/ShortUrl.json" + } + } + }, + "examples": { + "application/json": { + "shortCode": "12Kb3", + "shortUrl": "https://doma.in/12Kb3", + "longUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + }, + "domain": null + } + } }, "400": { "description": "Provided meta arguments are invalid.", diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index dc960fb4..7891b2a0 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -59,7 +59,7 @@ return [ Service\UrlShortener::class, 'config.url_shortener.domain', ], - Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class], + Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class], Action\ShortUrl\ResolveShortUrlAction::class => [ Service\ShortUrl\ShortUrlResolver::class, diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index d7d283c7..672d3963 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; -use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -19,10 +20,12 @@ class EditShortUrlAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; private ShortUrlServiceInterface $shortUrlService; + private ShortUrlDataTransformer $transformer; - public function __construct(ShortUrlServiceInterface $shortUrlService) + public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) { $this->shortUrlService = $shortUrlService; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } public function handle(ServerRequestInterface $request): ResponseInterface @@ -31,7 +34,8 @@ class EditShortUrlAction extends AbstractRestAction $identifier = ShortUrlIdentifier::fromApiRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->shortUrlService->updateShortUrl($identifier, $shortUrlEdit, $apiKey); - return new EmptyResponse(); + $shortUrl = $this->shortUrlService->updateShortUrl($identifier, $shortUrlEdit, $apiKey); + + return new JsonResponse($this->transformer->transform($shortUrl)); } } diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 84612b0f..6652c1a4 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -41,8 +41,8 @@ class EditShortUrlTest extends ApiTestCase ]); $metaAfterResetting = $this->findShortUrlMetaByShortCode($shortCode); - self::assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode()); - self::assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode()); + self::assertEquals(self::STATUS_OK, $editWithProvidedMeta->getStatusCode()); + self::assertEquals(self::STATUS_OK, $editWithResetMeta->getStatusCode()); self::assertEquals($resetMeta, $metaAfterResetting); self::assertArraySubset($meta, $metaAfterEditing); } @@ -93,7 +93,7 @@ class EditShortUrlTest extends ApiTestCase public function provideLongUrls(): iterable { - yield 'valid URL' => ['https://shlink.io', self::STATUS_NO_CONTENT, null]; + yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null]; yield 'invalid URL' => ['htt:foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; } @@ -155,7 +155,7 @@ class EditShortUrlTest extends ApiTestCase ]]); $editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url)); - self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); + self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); self::assertEquals($domain, $editedShortUrl['domain']); self::assertEquals($expectedUrl, $editedShortUrl['longUrl']); self::assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null); diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index d256a2ad..ca99f058 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -29,7 +29,7 @@ class ResolveShortUrlTest extends ApiTestCase $visitResp = $this->callShortUrl($shortCode); $fetchResp = $this->callApiWithKey(self::METHOD_GET, $url); - self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); + self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode()); self::assertEquals(self::STATUS_OK, $fetchResp->getStatusCode()); } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 7a7d19ee..be70eec8 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -25,7 +25,7 @@ class EditShortUrlActionTest extends TestCase public function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $this->action = new EditShortUrlAction($this->shortUrlService->reveal()); + $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), []); } /** @test */ @@ -54,7 +54,7 @@ class EditShortUrlActionTest extends TestCase $resp = $this->action->handle($request); - self::assertEquals(204, $resp->getStatusCode()); + self::assertEquals(200, $resp->getStatusCode()); $updateMeta->shouldHaveBeenCalled(); } } From 79ff12a1b047763e08c2a8e5bdd354ebead22801 Mon Sep 17 00:00:00 2001 From: Roy-Orbison Date: Mon, 1 Feb 2021 14:47:11 +1030 Subject: [PATCH 035/115] Allow serving of 0-byte, real files Essential for many HTTP challenges for domain verification, SSL cert issuance, etc. --- public/.htaccess | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/.htaccess b/public/.htaccess index a5c40815..32a4fd27 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,7 +1,7 @@ RewriteEngine On # The following rule tells Apache that if the requested filename # exists, simply serve it. -RewriteCond %{REQUEST_FILENAME} -s [OR] +RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -l [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^.*$ - [NC,L] From 01aebd90d5c6640efe14cb34b57d5911b957a6e2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Feb 2021 10:45:31 +0100 Subject: [PATCH 036/115] Added 988 link in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12517bc6..f1c408f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* *Nothing* +* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. ## [2.5.2] - 2021-01-24 From 9cddedcdbab90b9b2f3efc358c26f84517e2242a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Feb 2021 22:55:52 +0100 Subject: [PATCH 037/115] Extracted logic to stringify ShortUrls to its own service --- module/CLI/config/dependencies.config.php | 9 ++- .../ShortUrl/GenerateShortUrlCommand.php | 14 ++-- .../Command/ShortUrl/ListShortUrlsCommand.php | 8 +- .../ShortUrl/GenerateShortUrlCommandTest.php | 20 +++-- .../ShortUrl/ListShortUrlsCommandTest.php | 7 +- module/Core/config/dependencies.config.php | 8 +- .../Core/config/event_dispatcher.config.php | 2 +- module/Core/src/Action/QrCodeAction.php | 9 ++- module/Core/src/Entity/ShortUrl.php | 78 +++++++------------ .../EventDispatcher/NotifyVisitToWebHooks.php | 8 +- .../src/Mercure/MercureUpdatesGenerator.php | 8 +- .../ShortUrl/Helper/ShortUrlStringifier.php | 36 +++++++++ .../Helper/ShortUrlStringifierInterface.php | 12 +++ .../Transformer/ShortUrlDataTransformer.php | 11 +-- module/Core/test/Action/QrCodeActionTest.php | 6 +- .../NotifyVisitToWebHooksTest.php | 4 +- .../Mercure/MercureUpdatesGeneratorTest.php | 4 +- .../ShortUrlDataTransformerTest.php | 7 +- module/Rest/config/dependencies.config.php | 11 +-- .../ShortUrl/AbstractCreateShortUrlAction.php | 8 +- .../Action/ShortUrl/EditShortUrlAction.php | 8 +- .../Action/ShortUrl/ListShortUrlsAction.php | 8 +- .../Action/ShortUrl/ResolveShortUrlAction.php | 8 +- .../ShortUrl/CreateShortUrlActionTest.php | 21 ++--- .../ShortUrl/EditShortUrlActionTest.php | 6 +- .../ShortUrl/ListShortUrlsActionTest.php | 12 ++- .../ShortUrl/ResolveShortUrlActionTest.php | 6 +- .../SingleStepCreateShortUrlActionTest.php | 11 +-- 28 files changed, 215 insertions(+), 135 deletions(-) create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php rename module/Core/src/{ => ShortUrl}/Transformer/ShortUrlDataTransformer.php (77%) rename module/Core/test/{ => ShortUrl}/Transformer/ShortUrlDataTransformerTest.php (89%) diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 3c9d74ce..685dc9fd 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,8 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; @@ -64,11 +66,14 @@ return [ Command\ShortUrl\GenerateShortUrlCommand::class => [ Service\UrlShortener::class, - 'config.url_shortener.domain', + ShortUrlStringifier::class, 'config.url_shortener.default_short_codes_length', ], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class], - Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], + Command\ShortUrl\ListShortUrlsCommand::class => [ + Service\ShortUrlService::class, + ShortUrlDataTransformer::class, + ], Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index a618af83..cafd0e5a 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -30,14 +31,17 @@ class GenerateShortUrlCommand extends BaseCommand public const NAME = 'short-url:generate'; private UrlShortenerInterface $urlShortener; - private array $domainConfig; + private ShortUrlStringifierInterface $stringifier; private int $defaultShortCodeLength; - public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength) - { + public function __construct( + UrlShortenerInterface $urlShortener, + ShortUrlStringifierInterface $stringifier, + int $defaultShortCodeLength + ) { parent::__construct(); $this->urlShortener = $urlShortener; - $this->domainConfig = $domainConfig; + $this->stringifier = $stringifier; $this->defaultShortCodeLength = $defaultShortCodeLength; } @@ -163,7 +167,7 @@ class GenerateShortUrlCommand extends BaseCommand $io->writeln([ sprintf('Processed long URL: %s', $longUrl), - sprintf('Generated short URL: %s', $shortUrl->toString($this->domainConfig)), + sprintf('Generated short URL: %s', $this->stringifier->stringify($shortUrl)), ]); return ExitCodes::EXIT_SUCCESS; } catch (InvalidUrlException | NonUniqueSlugException $e) { diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index cf20e328..21beecaa 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -9,10 +9,10 @@ use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -42,13 +42,13 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ]; private ShortUrlServiceInterface $shortUrlService; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) + public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) { parent::__construct(); $this->shortUrlService = $shortUrlService; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } protected function doConfigure(): void diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index f2e8d610..25953d38 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -23,18 +24,17 @@ class GenerateShortUrlCommandTest extends TestCase { use ProphecyTrait; - private const DOMAIN_CONFIG = [ - 'schema' => 'http', - 'hostname' => 'foo.com', - ]; - private CommandTester $commandTester; private ObjectProphecy $urlShortener; + private ObjectProphecy $stringifier; public function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortener::class); - $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); + + $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5); $app = new Application(); $app->add($command); $this->commandTester = new CommandTester($command); @@ -45,6 +45,7 @@ class GenerateShortUrlCommandTest extends TestCase { $shortUrl = ShortUrl::createEmpty(); $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url'); $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', @@ -53,8 +54,9 @@ class GenerateShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); + self::assertStringContainsString('stringified_short_url', $output); $urlToShortCode->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); } /** @test */ @@ -97,6 +99,7 @@ class GenerateShortUrlCommandTest extends TestCase return true; }), )->willReturn($shortUrl); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url'); $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', @@ -105,8 +108,9 @@ class GenerateShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); + self::assertStringContainsString('stringified_short_url', $output); $urlToShortCode->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); } /** diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 784391e0..3f2b38b1 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -15,6 +15,8 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -31,7 +33,9 @@ class ListShortUrlsCommandTest extends TestCase { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); $app = new Application(); - $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), []); + $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( + new ShortUrlStringifier([]), + )); $app->add($command); $this->commandTester = new CommandTester($command); } @@ -56,6 +60,7 @@ class ListShortUrlsCommandTest extends TestCase self::assertStringContainsString('Continue with page 2?', $output); self::assertStringContainsString('Continue with page 3?', $output); self::assertStringContainsString('Continue with page 4?', $output); + self::assertStringNotContainsString('Continue with page 5?', $output); } /** @test */ diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 0eaa7a8e..7a5950bc 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -43,6 +43,8 @@ return [ Action\QrCodeAction::class => ConfigAbstractFactory::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, + ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class, + ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, @@ -114,13 +116,15 @@ return [ ], Action\QrCodeAction::class => [ Service\ShortUrl\ShortUrlResolver::class, - 'config.url_shortener.domain', + ShortUrl\Helper\ShortUrlStringifier::class, 'Logger_Shlink', ], ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], + ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain'], + ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], - Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'], + Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], Importer\ImportedLinksProcessor::class => [ 'em', diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 83390fdd..66a23637 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -53,7 +53,7 @@ return [ 'em', 'Logger_Shlink', 'config.url_shortener.visits_webhooks', - 'config.url_shortener.domain', + ShortUrl\Transformer\ShortUrlDataTransformer::class, Options\AppOptions::class, ], EventDispatcher\NotifyVisitToMercure::class => [ diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 919682d5..b39159fd 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; class QrCodeAction implements MiddlewareInterface { @@ -24,17 +25,17 @@ class QrCodeAction implements MiddlewareInterface private const MAX_SIZE = 1000; private ShortUrlResolverInterface $urlResolver; - private array $domainConfig; + private ShortUrlStringifierInterface $stringifier; private LoggerInterface $logger; public function __construct( ShortUrlResolverInterface $urlResolver, - array $domainConfig, + ShortUrlStringifierInterface $stringifier, ?LoggerInterface $logger = null ) { $this->urlResolver = $urlResolver; - $this->domainConfig = $domainConfig; $this->logger = $logger ?? new NullLogger(); + $this->stringifier = $stringifier; } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -52,7 +53,7 @@ class QrCodeAction implements MiddlewareInterface // Size attribute is deprecated $size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE)); - $qrCode = new QrCode($shortUrl->toString($this->domainConfig)); + $qrCode = new QrCode($this->stringifier->stringify($shortUrl)); $qrCode->setSize($size); $qrCode->setMargin(0); diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 2919be17..c41d506e 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Entity; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Laminas\Diactoros\Uri; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; @@ -128,6 +127,36 @@ class ShortUrl extends AbstractEntity return $this->tags; } + public function getValidSince(): ?Chronos + { + return $this->validSince; + } + + public function getValidUntil(): ?Chronos + { + return $this->validUntil; + } + + public function getVisitsCount(): int + { + return count($this->visits); + } + + /** + * @param Collection|Visit[] $visits + * @internal + */ + public function setVisits(Collection $visits): self + { + $this->visits = $visits; + return $this; + } + + public function getMaxVisits(): ?int + { + return $this->maxVisits; + } + public function update( ShortUrlEdit $shortUrlEdit, ?ShortUrlRelationResolverInterface $relationResolver = null @@ -168,36 +197,6 @@ class ShortUrl extends AbstractEntity $this->shortCode = generateRandomShortCode($this->shortCodeLength); } - public function getValidSince(): ?Chronos - { - return $this->validSince; - } - - public function getValidUntil(): ?Chronos - { - return $this->validUntil; - } - - public function getVisitsCount(): int - { - return count($this->visits); - } - - /** - * @param Collection|Visit[] $visits - * @internal - */ - public function setVisits(Collection $visits): self - { - $this->visits = $visits; - return $this; - } - - public function getMaxVisits(): ?int - { - return $this->maxVisits; - } - public function isEnabled(): bool { $maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits; @@ -218,21 +217,4 @@ class ShortUrl extends AbstractEntity return true; } - - public function toString(array $domainConfig): string - { - return (new Uri())->withPath($this->shortCode) - ->withScheme($domainConfig['schema'] ?? 'http') - ->withHost($this->resolveDomain($domainConfig['hostname'] ?? '')) - ->__toString(); - } - - private function resolveDomain(string $fallback = ''): string - { - if ($this->domain === null) { - return $fallback; - } - - return $this->domain->getAuthority(); - } } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 2add5698..d3b27602 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -12,10 +12,10 @@ use GuzzleHttp\Promise\Promise; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\RequestOptions; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\Options\AppOptions; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Throwable; use function Functional\map; @@ -29,7 +29,7 @@ class NotifyVisitToWebHooks private LoggerInterface $logger; /** @var string[] */ private array $webhooks; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; private AppOptions $appOptions; public function __construct( @@ -37,14 +37,14 @@ class NotifyVisitToWebHooks EntityManagerInterface $em, LoggerInterface $logger, array $webhooks, - array $domainConfig, + DataTransformerInterface $transformer, AppOptions $appOptions ) { $this->httpClient = $httpClient; $this->em = $em; $this->logger = $logger; $this->webhooks = $webhooks; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; $this->appOptions = $appOptions; } diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index aad072f2..bd00e836 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Mercure; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Symfony\Component\Mercure\Update; use function json_encode; @@ -17,11 +17,11 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface { private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(array $domainConfig) + public function __construct(DataTransformerInterface $transformer) { - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } public function newVisitUpdate(Visit $visit): Update diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php new file mode 100644 index 00000000..d5edba52 --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -0,0 +1,36 @@ +domainConfig = $domainConfig; + } + + public function stringify(ShortUrl $shortUrl): string + { + return (new Uri())->withPath($shortUrl->getShortCode()) + ->withScheme($this->domainConfig['schema'] ?? 'http') + ->withHost($this->resolveDomain($shortUrl)) + ->__toString(); + } + + private function resolveDomain(ShortUrl $shortUrl): string + { + $domain = $shortUrl->getDomain(); + if ($domain === null) { + return $this->domainConfig['hostname'] ?? ''; + } + + return $domain->getAuthority(); + } +} diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php new file mode 100644 index 00000000..360861ee --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php @@ -0,0 +1,12 @@ +domainConfig = $domainConfig; + $this->stringifier = $stringifier; } /** @@ -26,7 +27,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface { return [ 'shortCode' => $shortUrl->getShortCode(), - 'shortUrl' => $shortUrl->toString($this->domainConfig), + 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => $shortUrl->getVisitsCount(), diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 5593be7c..245ac6de 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use function getimagesizefromstring; @@ -37,7 +38,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->action = new QrCodeAction($this->urlResolver->reveal(), ['domain' => 'doma.in']); + $this->action = new QrCodeAction( + $this->urlResolver->reveal(), + new ShortUrlStringifier(['domain' => 'doma.in']), + ); } /** @test */ diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index ff382f13..9599c2c8 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -23,6 +23,8 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use function count; use function Functional\contains; @@ -127,7 +129,7 @@ class NotifyVisitToWebHooksTest extends TestCase $this->em->reveal(), $this->logger->reveal(), $webhooks, - [], + new ShortUrlDataTransformer(new ShortUrlStringifier([])), new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']), ); } diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index b7382f84..c3a8463f 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use function Shlinkio\Shlink\Common\json_decode; @@ -19,7 +21,7 @@ class MercureUpdatesGeneratorTest extends TestCase public function setUp(): void { - $this->generator = new MercureUpdatesGenerator([]); + $this->generator = new MercureUpdatesGenerator(new ShortUrlDataTransformer(new ShortUrlStringifier([]))); } /** diff --git a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php similarity index 89% rename from module/Core/test/Transformer/ShortUrlDataTransformerTest.php rename to module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index 0a621799..81c0d203 100644 --- a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Transformer; +namespace ShlinkioTest\Shlink\Core\ShortUrl\Transformer; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use function random_int; @@ -18,7 +19,7 @@ class ShortUrlDataTransformerTest extends TestCase public function setUp(): void { - $this->transformer = new ShortUrlDataTransformer([]); + $this->transformer = new ShortUrlDataTransformer(new ShortUrlStringifier([])); } /** diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 7891b2a0..cfb97320 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -54,21 +55,21 @@ return [ Action\HealthAction::class => ['em', AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], - Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'], + Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, ShortUrlDataTransformer::class], Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ Service\UrlShortener::class, - 'config.url_shortener.domain', + ShortUrlDataTransformer::class, ], - Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], + Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class], Action\ShortUrl\ResolveShortUrlAction::class => [ Service\ShortUrl\ShortUrlResolver::class, - 'config.url_shortener.domain', + ShortUrlDataTransformer::class, ], Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class], Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::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, ShortUrlDataTransformer::class], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], Action\Tag\ListTagsAction::class => [TagService::class], Action\Tag\DeleteTagsAction::class => [TagService::class], diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index a7278457..587c4bc5 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -7,21 +7,21 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { private UrlShortenerInterface $urlShortener; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig) + public function __construct(UrlShortenerInterface $urlShortener, DataTransformerInterface $transformer) { $this->urlShortener = $urlShortener; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 672d3963..49187314 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -20,12 +20,12 @@ class EditShortUrlAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; private ShortUrlServiceInterface $shortUrlService; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) + public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) { $this->shortUrlService = $shortUrlService; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index cd1bb4af..ee077790 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -8,9 +8,9 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -22,12 +22,12 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private ShortUrlServiceInterface $shortUrlService; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) + public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) { $this->shortUrlService = $shortUrlService; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index fafd15df..c14423ce 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -19,12 +19,12 @@ class ResolveShortUrlAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private ShortUrlResolverInterface $urlResolver; - private ShortUrlDataTransformer $transformer; + private DataTransformerInterface $transformer; - public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig) + public function __construct(ShortUrlResolverInterface $urlResolver, DataTransformerInterface $transformer) { $this->urlResolver = $urlResolver; - $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->transformer = $transformer; } public function handle(Request $request): Response diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 4d4d5468..f8e95659 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Cake\Chronos\Chronos; +use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -18,24 +20,21 @@ use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function strpos; - class CreateShortUrlActionTest extends TestCase { use ProphecyTrait; - private const DOMAIN_CONFIG = [ - 'schema' => 'http', - 'hostname' => 'foo.com', - ]; - private CreateShortUrlAction $action; private ObjectProphecy $urlShortener; + private ObjectProphecy $transformer; public function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortener::class); - $this->action = new CreateShortUrlAction($this->urlShortener->reveal(), self::DOMAIN_CONFIG); + $this->transformer = $this->prophesize(DataTransformerInterface::class); + $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); + + $this->action = new CreateShortUrlAction($this->urlShortener->reveal(), $this->transformer->reveal()); } /** @test */ @@ -55,14 +54,18 @@ class CreateShortUrlActionTest extends TestCase $expectedMeta['apiKey'] = $apiKey; $shorten = $this->urlShortener->shorten(ShortUrlMeta::fromRawData($expectedMeta))->willReturn($shortUrl); + $transform = $this->transformer->transform($shortUrl)->willReturn(['shortUrl' => 'stringified_short_url']); $request = ServerRequestFactory::fromGlobals()->withParsedBody($body)->withAttribute(ApiKey::class, $apiKey); + /** @var JsonResponse $response */ $response = $this->action->handle($request); + $payload = $response->getPayload(); self::assertEquals(200, $response->getStatusCode()); - self::assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0); + self::assertEquals('stringified_short_url', $payload['shortUrl']); $shorten->shouldHaveBeenCalledOnce(); + $transform->shouldHaveBeenCalledOnce(); } /** diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index be70eec8..eee75dbf 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -12,6 +12,8 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -25,7 +27,9 @@ class EditShortUrlActionTest extends TestCase public function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), []); + $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), new ShortUrlDataTransformer( + new ShortUrlStringifier([]), + )); } /** @test */ diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index fd51fa16..2683b514 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -14,6 +14,8 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlService; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -28,10 +30,12 @@ class ListShortUrlsActionTest extends TestCase { $this->service = $this->prophesize(ShortUrlService::class); - $this->action = new ListShortUrlsAction($this->service->reveal(), [ - 'hostname' => 'doma.in', - 'schema' => 'https', - ]); + $this->action = new ListShortUrlsAction($this->service->reveal(), new ShortUrlDataTransformer( + new ShortUrlStringifier([ + 'hostname' => 'doma.in', + 'schema' => 'https', + ]), + )); } /** diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 2f47089f..748ab642 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -11,6 +11,8 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -26,7 +28,9 @@ class ResolveShortUrlActionTest extends TestCase public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), []); + $this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), new ShortUrlDataTransformer( + new ShortUrlStringifier([]), + )); } /** @test */ diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 9dbbb716..f78a9de5 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -6,8 +6,10 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -20,18 +22,17 @@ class SingleStepCreateShortUrlActionTest extends TestCase private SingleStepCreateShortUrlAction $action; private ObjectProphecy $urlShortener; - private ObjectProphecy $apiKeyService; + private ObjectProphecy $transformer; public function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortenerInterface::class); + $this->transformer = $this->prophesize(DataTransformerInterface::class); + $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); $this->action = new SingleStepCreateShortUrlAction( $this->urlShortener->reveal(), - [ - 'schema' => 'http', - 'hostname' => 'foo.com', - ], + $this->transformer->reveal(), ); } From 4b4a859722aedb01742051428de18ccc792fb1a9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Feb 2021 23:08:30 +0100 Subject: [PATCH 038/115] Created ShortUrlStringifierTest --- .../Helper/ShortUrlStringifierTest.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php new file mode 100644 index 00000000..80cff5ed --- /dev/null +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -0,0 +1,55 @@ +stringify($shortUrl)); + } + + public function provideConfigAndShortUrls(): iterable + { + $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::fromMeta( + ShortUrlMeta::fromRawData([ + 'longUrl' => '', + 'customSlug' => $shortCode, + 'domain' => $domain, + ]), + ); + + yield 'no config' => [[], $shortUrlWithShortCode('foo'), 'http:/foo']; + yield 'hostname in config' => [ + ['hostname' => 'example.com'], + $shortUrlWithShortCode('bar'), + 'http://example.com/bar', + ]; + yield 'full config' => [ + ['schema' => 'https', 'hostname' => 'foo.com'], + $shortUrlWithShortCode('baz'), + 'https://foo.com/baz', + ]; + yield 'custom domain' => [ + ['schema' => 'https', 'hostname' => 'foo.com'], + $shortUrlWithShortCode('baz', 'mydom.es'), + 'https://mydom.es/baz', + ]; + } +} From 8fa0c95f5ab0d95b74d64d315c0488124e9cad63 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Feb 2021 23:18:19 +0100 Subject: [PATCH 039/115] Ensured base path is honored when stringifying short URLs with a custom domain --- module/Core/config/dependencies.config.php | 2 +- .../ShortUrl/Helper/ShortUrlStringifier.php | 8 ++++++-- .../Helper/ShortUrlStringifierTest.php | 20 +++++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7a5950bc..48166aeb 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -121,7 +121,7 @@ return [ ], ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], - ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain'], + ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index d5edba52..4d34e26b 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -7,13 +7,17 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use function sprintf; + class ShortUrlStringifier implements ShortUrlStringifierInterface { private array $domainConfig; + private string $basePath; - public function __construct(array $domainConfig) + public function __construct(array $domainConfig, string $basePath = '') { $this->domainConfig = $domainConfig; + $this->basePath = $basePath; } public function stringify(ShortUrl $shortUrl): string @@ -31,6 +35,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface return $this->domainConfig['hostname'] ?? ''; } - return $domain->getAuthority(); + return sprintf('%s%s', $domain->getAuthority(), $this->basePath); } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index 80cff5ed..483fd57d 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -17,10 +17,11 @@ class ShortUrlStringifierTest extends TestCase */ public function generatesExpectedOutputBasedOnConfigAndShortUrl( array $config, + string $basePath, ShortUrl $shortUrl, string $expected ): void { - $stringifier = new ShortUrlStringifier($config); + $stringifier = new ShortUrlStringifier($config, $basePath); self::assertEquals($expected, $stringifier->stringify($shortUrl)); } @@ -35,21 +36,36 @@ class ShortUrlStringifierTest extends TestCase ]), ); - yield 'no config' => [[], $shortUrlWithShortCode('foo'), 'http:/foo']; + yield 'no config' => [[], '', $shortUrlWithShortCode('foo'), 'http:/foo']; yield 'hostname in config' => [ ['hostname' => 'example.com'], + '', $shortUrlWithShortCode('bar'), 'http://example.com/bar', ]; + yield 'hostname with base path in config' => [ + ['hostname' => 'example.com/foo/bar'], + '', + $shortUrlWithShortCode('abc'), + 'http://example.com/foo/bar/abc', + ]; yield 'full config' => [ ['schema' => 'https', 'hostname' => 'foo.com'], + '', $shortUrlWithShortCode('baz'), 'https://foo.com/baz', ]; yield 'custom domain' => [ ['schema' => 'https', 'hostname' => 'foo.com'], + '', $shortUrlWithShortCode('baz', 'mydom.es'), 'https://mydom.es/baz', ]; + yield 'custom domain with base path' => [ + ['schema' => 'https', 'hostname' => 'foo.com'], + '/foo/bar', + $shortUrlWithShortCode('baz', 'mydom.es'), + 'https://mydom.es/foo/bar/baz', + ]; } } From e20df481a4d366b544f2fe437b4d56ea0eb7e0ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Feb 2021 23:20:48 +0100 Subject: [PATCH 040/115] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c408f0..6079f750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. +* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used. ## [2.5.2] - 2021-01-24 From 31a7212a712af844acd6ddba745b3a16e67bfbff Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 2 Feb 2021 21:19:38 +0100 Subject: [PATCH 041/115] Improvements in CONTRIBUTING doc --- CONTRIBUTING.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03d7858d..234bab5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Then you will have to follow these steps: Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole. -> Note: The `indocker` shell script is a helper used to run commands inside the main docker container. +> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container. ## Project structure @@ -88,9 +88,9 @@ In order to ensure stability and no regressions are introduced while developing * **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks. - The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature. + The code coverage of unit tests is pretty high, and only components which work closer to the database, like entity repositories, are excluded because of their nature. -* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories. +* **Database tests**: These are integration tests that run against a real database, and only cover components which work closer to the database. Its purpose is to verify all the database queries behave as expected and return what's expected. @@ -98,7 +98,7 @@ In order to ensure stability and no regressions are introduced while developing * **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API. - These are the best tests to catch regressions, and to verify everything interacts as expected. + These are the best tests to catch regressions, and to verify everything behaves as expected. They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution. @@ -114,13 +114,14 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * Run `./indocker composer test:unit` to run the unit tests. * Run `./indocker composer test:db` to run the database integration tests. - This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command. + This command runs the same test suite against all supported database engines in parallel. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command. For example, `test:db:postgres`. * Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used. * Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). * Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration. +* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible. > Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite). > @@ -130,11 +131,15 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, ## Pull request process -In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes. +**Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first. + +This is important because any contribution needs to be discussed first. Maybe there's someone else already working on something similar, or there are other considerations to have in mind. + +Once everything is clear, to provide a pull request to this project, you should always start by creating a new branch, where you will make all desired changes. The base branch should always be `develop`, and the target branch for the pull request should also be `develop`. -Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created. +Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created. ## Architectural Decision Records From 430c407106d0dfb7474580caa54835f3745f91cb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 2 Feb 2021 20:21:48 +0100 Subject: [PATCH 042/115] Added support for an optional title field in short URLs --- data/migrations/Version20210202181026.php | 32 +++++++++++++++++++ .../Shlinkio.Shlink.Core.Entity.ShortUrl.php | 6 ++++ module/Core/src/Entity/ShortUrl.php | 7 ++++ module/Core/src/Model/ShortUrlMeta.php | 7 ++++ module/Core/src/Model/ShortUrlsOrdering.php | 7 ++-- .../Transformer/ShortUrlDataTransformer.php | 1 + .../src/Validation/ShortUrlInputFilter.php | 5 +++ .../Mercure/MercureUpdatesGeneratorTest.php | 13 +++++--- 8 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 data/migrations/Version20210202181026.php diff --git a/data/migrations/Version20210202181026.php b/data/migrations/Version20210202181026.php new file mode 100644 index 00000000..00e82a40 --- /dev/null +++ b/data/migrations/Version20210202181026.php @@ -0,0 +1,32 @@ +getTable('short_urls'); + $this->skipIf($shortUrls->hasColumn(self::TITLE)); + + $shortUrls->addColumn(self::TITLE, Types::STRING, [ + 'notnull' => false, + 'length' => 512, + ]); + } + + public function down(Schema $schema): void + { + $shortUrls = $schema->getTable('short_urls'); + $this->skipIf(! $shortUrls->hasColumn(self::TITLE)); + $shortUrls->dropColumn(self::TITLE); + } +} 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 da4506af..df17ad95 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 @@ -84,4 +84,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); + + $builder->createField('title', Types::STRING) + ->columnName('title') + ->length(512) + ->nullable() + ->build(); }; diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index c41d506e..fd061996 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -38,6 +38,7 @@ class ShortUrl extends AbstractEntity private ?string $importSource = null; private ?string $importOriginalShortCode = null; private ?ApiKey $authorApiKey = null; + private ?string $title = null; private function __construct() { @@ -72,6 +73,7 @@ class ShortUrl extends AbstractEntity $instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); $instance->domain = $relationResolver->resolveDomain($meta->getDomain()); $instance->authorApiKey = $meta->getApiKey(); + $instance->title = $meta->getTitle(); return $instance; } @@ -157,6 +159,11 @@ class ShortUrl extends AbstractEntity return $this->maxVisits; } + public function getTitle(): ?string + { + return $this->title; + } + public function update( ShortUrlEdit $shortUrlEdit, ?ShortUrlRelationResolverInterface $relationResolver = null diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 13f36362..65ff5e1e 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -28,6 +28,7 @@ final class ShortUrlMeta private ?bool $validateUrl = null; private ?ApiKey $apiKey = null; private array $tags = []; + private ?string $title = null; private function __construct() { @@ -76,6 +77,7 @@ final class ShortUrlMeta ) ?? DEFAULT_SHORT_CODES_LENGTH; $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); + $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); } public function getLongUrl(): string @@ -160,4 +162,9 @@ final class ShortUrlMeta { return $this->tags; } + + public function getTitle(): ?string + { + return $this->title; + } } diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index e1708a86..b59435ca 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Core\Exception\ValidationException; +use function array_pad; use function explode; use function is_array; use function is_string; @@ -50,9 +51,9 @@ final class ShortUrlsOrdering /** @var string|array $orderBy */ if (! $isArray) { - $parts = explode('-', $orderBy); - $this->orderField = $parts[0]; - $this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION; + [$field, $dir] = array_pad(explode('-', $orderBy), 2, null); + $this->orderField = $field; + $this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION; } else { $this->orderField = key($orderBy); $this->orderDirection = $orderBy[$this->orderField]; diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 462178a0..ce459714 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -34,6 +34,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), 'domain' => $shortUrl->getDomain(), + 'title' => $shortUrl->getTitle(), ]; } diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index fa333b49..b5d4fa07 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -31,6 +31,7 @@ class ShortUrlInputFilter extends InputFilter public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; public const TAGS = 'tags'; + public const TITLE = 'title'; private function __construct(array $data, bool $requireLongUrl) { @@ -87,6 +88,8 @@ class ShortUrlInputFilter extends InputFilter $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); + // This cannot be defined as a boolean input because it can actually have 3 values, true, false and null. + // Defining it as boolean will make null fall back to false, which is not the desired behavior. $this->add($this->createInput(self::VALIDATE_URL, false)); $domain = $this->createInput(self::DOMAIN, false); @@ -100,5 +103,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($apiKeyInput); $this->add($this->createTagsInput(self::TAGS, false)); + + $this->add($this->createInput(self::TITLE, false)); } } diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index c3a8463f..435fb4d8 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -28,9 +28,13 @@ class MercureUpdatesGeneratorTest extends TestCase * @test * @dataProvider provideMethod */ - public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void + public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['customSlug' => 'foo', 'longUrl' => ''])); + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'customSlug' => 'foo', + 'longUrl' => '', + 'title' => $title, + ])); $visit = new Visit($shortUrl, Visitor::emptyInstance()); $update = $this->generator->{$method}($visit); @@ -50,6 +54,7 @@ class MercureUpdatesGeneratorTest extends TestCase 'maxVisits' => null, ], 'domain' => null, + 'title' => $title, ], 'visit' => [ 'referer' => '', @@ -62,7 +67,7 @@ class MercureUpdatesGeneratorTest extends TestCase public function provideMethod(): iterable { - yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit']; - yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo']; + yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit', 'the cool title']; + yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo', null]; } } From 356e68ca3e5d9f80604c2e754e6524b649dd67cd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 2 Feb 2021 20:51:28 +0100 Subject: [PATCH 043/115] Documented new title prop in swagger docs --- docs/swagger/definitions/ShortUrl.json | 6 ++++++ docs/swagger/paths/v1_short-urls.json | 13 +++++++++--- docs/swagger/paths/v1_short-urls_shorten.json | 3 ++- .../paths/v1_short-urls_{shortCode}.json | 20 ++++++++++++++----- module/Core/src/Entity/ShortUrl.php | 3 +++ module/Core/src/Model/ShortUrlEdit.php | 14 +++++++++++++ .../test-api/Action/ListShortUrlsTest.php | 6 ++++++ .../test-api/Fixtures/ShortUrlsFixture.php | 1 + 8 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 66d20115..3e4c6ead 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -34,7 +34,13 @@ }, "domain": { "type": "string", + "nullable": true, "description": "The domain in which the short URL was created. Null if it belongs to default domain." + }, + "title": { + "type": "string", + "nullable": true, + "description": "A descriptive title of the short URL." } } } diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 1dc10978..c3db1f05 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -137,7 +137,8 @@ "validUntil": null, "maxVisits": 100 }, - "domain": null + "domain": null, + "title": "Welcome to Steam" }, { "shortCode": "12Kb3", @@ -153,7 +154,8 @@ "validUntil": null, "maxVisits": null }, - "domain": null + "domain": null, + "title": null }, { "shortCode": "123bA", @@ -167,7 +169,8 @@ "validUntil": null, "maxVisits": null }, - "domain": "example.com" + "domain": "example.com", + "title": null } ], "pagination": { @@ -264,6 +267,10 @@ "validateUrl": { "description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", "type": "boolean" + }, + "title": { + "type": "string", + "description": "A descriptive title of the short URL." } } } diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index c31c0cd9..b6184d8d 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -73,7 +73,8 @@ "validUntil": null, "maxVisits": 100 }, - "domain": null + "domain": null, + "title": null }, "text/plain": "https://doma.in/abc123" } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 6cfa3c97..2281d9b8 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -53,7 +53,8 @@ "validUntil": null, "maxVisits": 100 }, - "domain": null + "domain": null, + "title": null } } }, @@ -118,15 +119,18 @@ }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", - "type": "string" + "type": "string", + "nullable": true }, "validUntil": { "description": "The date (in ISO-8601 format) until which this short code will be valid", - "type": "string" + "type": "string", + "nullable": true }, "maxVisits": { "description": "The maximum number of allowed visits for this short code", - "type": "number" + "type": "number", + "nullable": true }, "validateUrl": { "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", @@ -138,6 +142,11 @@ "type": "string" }, "description": "The list of tags to set to the short URL." + }, + "title": { + "type": "string", + "description": "A descriptive title of the short URL.", + "nullable": true } } } @@ -174,7 +183,8 @@ "validUntil": null, "maxVisits": 100 }, - "domain": null + "domain": null, + "title": "Shlink - The URL shortener" } } }, diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index fd061996..ba7f34f4 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -184,6 +184,9 @@ class ShortUrl extends AbstractEntity $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); } + if ($shortUrlEdit->hasTitle()) { + $this->title = $shortUrlEdit->title(); + } } /** diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index b8cb0e0c..ddb7d317 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -25,6 +25,8 @@ final class ShortUrlEdit private ?int $maxVisits = null; private bool $tagsPropWasProvided = false; private array $tags = []; + private bool $titlePropWasProvided = false; + private ?string $title = null; private ?bool $validateUrl = null; private function __construct() @@ -56,6 +58,7 @@ final class ShortUrlEdit $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); + $this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); @@ -63,6 +66,7 @@ final class ShortUrlEdit $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL); $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); + $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); } public function longUrl(): ?string @@ -118,6 +122,16 @@ final class ShortUrlEdit return $this->tagsPropWasProvided; } + public function title(): ?string + { + return $this->title; + } + + public function hasTitle(): bool + { + return $this->titlePropWasProvided; + } + public function doValidateUrl(): ?bool { return $this->validateUrl; diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e38374c8..2c7f35ae 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -25,6 +25,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => null, + 'title' => 'Shlink', ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', @@ -39,6 +40,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => null, + 'title' => null, ]; private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [ 'shortCode' => 'custom-with-domain', @@ -53,6 +55,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => 'some-domain.com', + 'title' => null, ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', @@ -69,6 +72,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => null, + 'title' => null, ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', @@ -83,6 +87,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => 2, ], 'domain' => null, + 'title' => null, ]; private const SHORT_URL_CUSTOM_DOMAIN = [ 'shortCode' => 'ghi789', @@ -99,6 +104,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => 'example.com', + 'title' => null, ]; /** diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 93defe90..426bc4f4 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -34,6 +34,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io', 'tags' => ['foo'], + 'title' => 'Shlink', ]), $relationResolver), '2018-05-01', ); From 8b5409829974bd5af02bea8aaaf34238297c5906 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Feb 2021 11:07:47 +0100 Subject: [PATCH 044/115] Added option to automatically resolve url titles --- config/autoload/url-shortener.global.php | 1 + module/Core/src/Model/ShortUrlMeta.php | 13 ++++++++ .../Core/src/Options/UrlShortenerOptions.php | 12 ++++++++ module/Core/src/Service/UrlShortener.php | 10 +++++-- module/Core/src/Util/UrlValidator.php | 30 +++++++++++++++++-- .../Core/src/Util/UrlValidatorInterface.php | 5 ++++ module/Core/test/Service/UrlShortenerTest.php | 4 +-- 7 files changed, 68 insertions(+), 7 deletions(-) diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index f27210af..f4a7966a 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -19,6 +19,7 @@ return [ 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, + 'auto_resolve_titles' => false, // Deprecated value. Default to true with Shlink 3.0.0 ], ]; diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 65ff5e1e..a069062c 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -167,4 +167,17 @@ final class ShortUrlMeta { return $this->title; } + + public function hasTitle(): bool + { + return $this->title !== null; + } + + public function withResolvedTitle(?string $title): self + { + $copy = clone $this; + $copy->title = $title; + + return $copy; + } } diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 92bb7d07..553a160f 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -18,6 +18,7 @@ class UrlShortenerOptions extends AbstractOptions private bool $validateUrl = true; private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; + private bool $autoResolveTitles = false; // Deprecated value. Default to true with Shlink 3.0.0 public function isUrlValidationEnabled(): bool { @@ -55,4 +56,15 @@ class UrlShortenerOptions extends AbstractOptions ? $redirectCacheLifetime : DEFAULT_REDIRECT_CACHE_LIFETIME; } + + public function autoResolveTitles(): bool + { + return $this->autoResolveTitles; + } + + protected function setAutoResolveTitles(bool $autoResolveTitles): self + { + $this->autoResolveTitles = $autoResolveTitles; + return $this; + } } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index f8125524..aa0908fe 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -13,7 +13,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; -use Throwable; class UrlShortener implements UrlShortenerInterface { @@ -37,7 +36,6 @@ class UrlShortener implements UrlShortenerInterface /** * @throws NonUniqueSlugException * @throws InvalidUrlException - * @throws Throwable */ public function shorten(ShortUrlMeta $meta): ShortUrl { @@ -47,7 +45,13 @@ class UrlShortener implements UrlShortenerInterface return $existingShortUrl; } - $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); + if ($meta->hasTitle()) { + $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); + } else { + $meta = $meta->withResolvedTitle( + $this->urlValidator->validateUrlWithTitle($meta->getLongUrl(), $meta->doValidateUrl()), + ); + } return $this->em->transactional(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index ccf69dd1..1f590de5 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -8,9 +8,12 @@ use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use function preg_match; + class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { private const MAX_REDIRECTS = 15; @@ -35,13 +38,36 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface return; } + $this->validateUrlAndGetResponse($url, true); + } + + public function validateUrlWithTitle(string $url, ?bool $doValidate): ?string + { + $doValidate = $doValidate ?? $this->options->isUrlValidationEnabled(); + $response = $this->validateUrlAndGetResponse($url, $doValidate); + + if ($response === null || ! $this->options->autoResolveTitles()) { + return null; + } + + $body = $response->getBody()->__toString(); + preg_match('/(.+)<\/title>/i', $body, $matches); + return $matches[1] ?? null; + } + + private function validateUrlAndGetResponse(string $url, bool $throwOnError): ?ResponseInterface + { try { - $this->httpClient->request(self::METHOD_GET, $url, [ + return $this->httpClient->request(self::METHOD_GET, $url, [ RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], RequestOptions::IDN_CONVERSION => true, ]); } catch (GuzzleException $e) { - throw InvalidUrlException::fromUrl($url, $e); + if ($throwOnError) { + throw InvalidUrlException::fromUrl($url, $e); + } + + return null; } } } diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php index fdf1e781..f198d301 100644 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ b/module/Core/src/Util/UrlValidatorInterface.php @@ -12,4 +12,9 @@ interface UrlValidatorInterface * @throws InvalidUrlException */ public function validateUrl(string $url, ?bool $doValidate): void; + + /** + * @throws InvalidUrlException + */ + public function validateUrlWithTitle(string $url, ?bool $doValidate): ?string; } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index a9ba783f..24abf69f 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -31,7 +31,7 @@ class UrlShortenerTest extends TestCase public function setUp(): void { $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); - $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar', null)->will( + $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', null)->will( function (): void { }, ); @@ -101,7 +101,7 @@ class UrlShortenerTest extends TestCase $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->urlValidator->validateUrlWithTitle(Argument::cetera())->shouldNotHaveBeenCalled(); self::assertSame($expected, $result); } From 71f85350dae68b4d518cd1186ff96c097ebcae89 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandrocelaya@gmail.com> Date: Wed, 3 Feb 2021 11:28:40 +0100 Subject: [PATCH 045/115] Fixed regex to parse title from URL to consider possible attributes --- module/Core/src/Util/UrlValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 1f590de5..e1ae159b 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -51,7 +51,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface } $body = $response->getBody()->__toString(); - preg_match('/<title>(.+)<\/title>/i', $body, $matches); + preg_match('/<title[^>]*>(.*?)<\/title>/i', $body, $matches); return $matches[1] ?? null; } From bfba05c8634a739ffcb49de7be3204523331c6db Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandrocelaya@gmail.com> Date: Wed, 3 Feb 2021 11:53:08 +0100 Subject: [PATCH 046/115] Enhanced UrlValidatorTest --- module/Core/functions/functions.php | 1 + module/Core/src/Util/UrlValidator.php | 7 ++- module/Core/test/Util/UrlValidatorTest.php | 50 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 531f8038..f9a67e3d 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -26,6 +26,7 @@ const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars +const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag function generateRandomShortCode(int $length): string { diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index e1ae159b..8d05cbe6 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -13,6 +13,9 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use function preg_match; +use function trim; + +use const Shlinkio\Shlink\Core\TITLE_TAG_VALUE; class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { @@ -51,8 +54,8 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface } $body = $response->getBody()->__toString(); - preg_match('/<title[^>]*>(.*?)<\/title>/i', $body, $matches); - return $matches[1] ?? null; + preg_match(TITLE_TAG_VALUE, $body, $matches); + return isset($matches[1]) ? trim($matches[1]) : null; } private function validateUrlAndGetResponse(string $url, bool $throwOnError): ?ResponseInterface diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index fab1db1e..7c5f7c55 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -9,6 +9,7 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Response; +use Laminas\Diactoros\Stream; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -76,10 +77,59 @@ class UrlValidatorTest extends TestCase $request->shouldNotHaveBeenCalled(); } + /** + * @test + * @dataProvider provideDisabledCombinations + */ + public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled( + ?bool $doValidate, + bool $validateUrl + ): void { + $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); + $this->options->validateUrl = $validateUrl; + + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', $doValidate); + + self::assertNull($result); + $request->shouldHaveBeenCalledOnce(); + } + public function provideDisabledCombinations(): iterable { yield 'config is disabled and no runtime option is provided' => [null, false]; yield 'config is enabled but runtime option is disabled' => [false, true]; yield 'both config and runtime option are disabled' => [false, false]; } + + /** @test */ + public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void + { + $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); + $this->options->autoResolveTitles = false; + + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + + self::assertNull($result); + $request->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void + { + $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); + $this->options->autoResolveTitles = true; + + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + + self::assertEquals('Resolved title', $result); + $request->shouldHaveBeenCalledOnce(); + } + + private function respWithTitle(): Response + { + $body = new Stream('php://temp', 'wr'); + $body->write('<title> Resolved title'); + + return new Response($body); + } } From 0ef1e347e764058743dd19a73272323af593c834 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Feb 2021 13:28:51 +0100 Subject: [PATCH 047/115] Enhanced UrlShortenerTest --- module/Core/test/Service/UrlShortenerTest.php | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 24abf69f..beeda044 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -31,10 +31,6 @@ class UrlShortenerTest extends TestCase public function setUp(): void { $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); - $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', null)->will( - function (): void { - }, - ); $this->em = $this->prophesize(EntityManagerInterface::class); $this->em->persist(Argument::any())->will(function ($arguments): void { @@ -63,14 +59,26 @@ class UrlShortenerTest extends TestCase ); } - /** @test */ - public function urlIsProperlyShortened(): void + /** + * @test + * @dataProvider provideTitles + */ + public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void { - $shortUrl = $this->urlShortener->shorten( - ShortUrlMeta::fromRawData(['longUrl' => 'http://foobar.com/12345/hello?foo=bar']), - ); + $longUrl = 'http://foobar.com/12345/hello?foo=bar'; + $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData(['longUrl' => $longUrl, 'title' => $title])); self::assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); + $this->urlValidator->validateUrlWithTitle($longUrl, null)->shouldHaveBeenCalledTimes( + $validateWithTitleCallsNum, + ); + $this->urlValidator->validateUrl($longUrl, null)->shouldHaveBeenCalledTimes($validateCallsNum); + } + + public function provideTitles(): iterable + { + yield 'no title' => [null, 1, 0]; + yield 'title' => ['link title', 0, 1]; } /** @test */ @@ -101,6 +109,7 @@ class UrlShortenerTest extends TestCase $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); $this->urlValidator->validateUrlWithTitle(Argument::cetera())->shouldNotHaveBeenCalled(); self::assertSame($expected, $result); } From 1da66f272c58be2bbc925b20e9b681aae50c21ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Feb 2021 13:41:37 +0100 Subject: [PATCH 048/115] Added AUTO_RESOLVE_TITLES env var for the docker image --- docker/config/shlink_in_docker.local.php | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index c6d7f69e..070f0974 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -125,6 +125,7 @@ return [ 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), + 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), // Deprecated value. Default to true ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), From 7192480751be9ef142539627b0a445fa07ad7ecd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Feb 2021 18:26:50 +0100 Subject: [PATCH 049/115] Update installer version --- composer.json | 4 ++-- config/autoload/installer.global.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 8776d34d..22473890 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.1", - "shlinkio/shlink-installer": "^5.3", + "shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", @@ -64,7 +64,7 @@ "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.2.1", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.20.2", + "infection/infection": "^0.21.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^0.12.64", "phpunit/php-code-coverage": "^9.2", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index a04d874b..7a355dbe 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -40,6 +40,7 @@ return [ Option\UrlShortener\IpAnonymizationConfigOption::class, Option\UrlShortener\RedirectStatusCodeConfigOption::class, Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, + Option\UrlShortener\AutoResolveTitlesConfigOption::class, ], 'installation_commands' => [ From 7824dddef723eb5aea9477a1a9330b4c77123614 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 3 Feb 2021 19:22:47 +0100 Subject: [PATCH 050/115] Added tracking to tell if short URL titles were autogenerated or not --- data/migrations/Version20210202181026.php | 12 ++++++++++++ .../Shlinkio.Shlink.Core.Entity.ShortUrl.php | 5 +++++ module/Core/src/Entity/ShortUrl.php | 2 ++ module/Core/src/Model/ShortUrlMeta.php | 9 ++++++++- module/Core/src/Service/UrlShortener.php | 19 ++++++++++++------- module/Core/test/Service/UrlShortenerTest.php | 14 ++++++++++++++ 6 files changed, 53 insertions(+), 8 deletions(-) diff --git a/data/migrations/Version20210202181026.php b/data/migrations/Version20210202181026.php index 00e82a40..c964559c 100644 --- a/data/migrations/Version20210202181026.php +++ b/data/migrations/Version20210202181026.php @@ -21,6 +21,9 @@ final class Version20210202181026 extends AbstractMigration 'notnull' => false, 'length' => 512, ]); + $shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [ + 'default' => false, + ]); } public function down(Schema $schema): void @@ -28,5 +31,14 @@ final class Version20210202181026 extends AbstractMigration $shortUrls = $schema->getTable('short_urls'); $this->skipIf(! $shortUrls->hasColumn(self::TITLE)); $shortUrls->dropColumn(self::TITLE); + $shortUrls->dropColumn('title_was_auto_resolved'); + } + + /** + * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 + */ + public function isTransactional(): bool + { + return false; } } 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 df17ad95..751e513c 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 @@ -90,4 +90,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->length(512) ->nullable() ->build(); + + $builder->createField('titleWasAutoResolved', Types::BOOLEAN) + ->columnName('title_was_auto_resolved') + ->option('default', false) + ->build(); }; diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index ba7f34f4..84c74874 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -39,6 +39,7 @@ class ShortUrl extends AbstractEntity private ?string $importOriginalShortCode = null; private ?ApiKey $authorApiKey = null; private ?string $title = null; + private bool $titleWasAutoResolved = false; private function __construct() { @@ -74,6 +75,7 @@ class ShortUrl extends AbstractEntity $instance->domain = $relationResolver->resolveDomain($meta->getDomain()); $instance->authorApiKey = $meta->getApiKey(); $instance->title = $meta->getTitle(); + $instance->titleWasAutoResolved = $meta->titleWasAutoResolved(); return $instance; } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index a069062c..a741f78c 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -29,6 +29,7 @@ final class ShortUrlMeta private ?ApiKey $apiKey = null; private array $tags = []; private ?string $title = null; + private bool $titleWasAutoResolved = false; private function __construct() { @@ -173,10 +174,16 @@ final class ShortUrlMeta return $this->title !== null; } - public function withResolvedTitle(?string $title): self + public function titleWasAutoResolved(): bool + { + return $this->titleWasAutoResolved; + } + + public function withResolvedTitle(string $title): self { $copy = clone $this; $copy->title = $title; + $copy->titleWasAutoResolved = true; return $copy; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index aa0908fe..6083e1f4 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -45,13 +45,7 @@ class UrlShortener implements UrlShortenerInterface return $existingShortUrl; } - if ($meta->hasTitle()) { - $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); - } else { - $meta = $meta->withResolvedTitle( - $this->urlValidator->validateUrlWithTitle($meta->getLongUrl(), $meta->doValidateUrl()), - ); - } + $meta = $this->processTitleAndValidateUrl($meta); return $this->em->transactional(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); @@ -88,4 +82,15 @@ class UrlShortener implements UrlShortenerInterface throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } } + + private function processTitleAndValidateUrl(ShortUrlMeta $meta): ShortUrlMeta + { + if ($meta->hasTitle()) { + $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); + return $meta; + } + + $title = $this->urlValidator->validateUrlWithTitle($meta->getLongUrl(), $meta->doValidateUrl()); + return $title === null ? $meta : $meta->withResolvedTitle($title); + } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index beeda044..9fbf82ed 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -75,6 +75,20 @@ class UrlShortenerTest extends TestCase $this->urlValidator->validateUrl($longUrl, null)->shouldHaveBeenCalledTimes($validateCallsNum); } + /** + * @test + * @dataProvider provideTitles + */ + public function urlIsProperlyShortenedWithExpectedResolvedTitle(?string $title): void + { + $validateWithTitle = $this->urlValidator->validateUrlWithTitle(Argument::cetera())->willReturn($title); + + $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData(['longUrl' => 'foo'])); + + self::assertEquals($title, $shortUrl->getTitle()); + $validateWithTitle->shouldHaveBeenCalledOnce(); + } + public function provideTitles(): iterable { yield 'no title' => [null, 1, 0]; From 2640c7b43c6d85442ff81834627ce517aea38736 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 4 Feb 2021 15:24:27 +0100 Subject: [PATCH 051/115] Updated to a shlink-importer version that supports titles --- composer.json | 2 +- module/Core/src/Entity/ShortUrl.php | 1 + .../Repository/ShortUrlRepositoryTest.php | 2 +- module/Core/test/Entity/ShortUrlTest.php | 2 +- .../Importer/ImportedLinksProcessorTest.php | 26 +++++++++---------- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index 22473890..3f314a2c 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-common": "dev-main#b889f5d as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", - "shlinkio/shlink-importer": "^2.1", + "shlinkio/shlink-importer": "dev-main#b6fc81f as 2.2", "shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 84c74874..53215a68 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -89,6 +89,7 @@ class ShortUrl extends AbstractEntity ShortUrlInputFilter::LONG_URL => $url->longUrl(), ShortUrlInputFilter::DOMAIN => $url->domain(), ShortUrlInputFilter::TAGS => $url->tags(), + ShortUrlInputFilter::TITLE => $url->title(), ShortUrlInputFilter::VALIDATE_URL => false, ]; if ($importShortCode) { diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index cf38d5a6..29694867 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -418,7 +418,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase public function importedShortUrlsAreSearchedAsExpected(): void { $buildImported = static fn (string $shortCode, ?String $domain = null) => - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode); + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); $this->getEntityManager()->persist($shortUrlWithoutDomain); diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 3cd607da..fceba3e2 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -64,7 +64,7 @@ class ShortUrlTest extends TestCase { yield 'no custom slug' => [ShortUrl::createEmpty()]; yield 'imported with custom slug' => [ - ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug'), true), + ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug', null), true), ]; } diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 174e9afc..c294ffe5 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -58,9 +58,9 @@ class ImportedLinksProcessorTest extends TestCase public function newUrlsWithNoErrorsAreAllPersisted(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', 'foo'), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null), ]; $expectedCalls = count($urls); @@ -80,11 +80,11 @@ class ImportedLinksProcessorTest extends TestCase public function alreadyImportedUrlsAreSkipped(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), - new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'), - new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'), + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null), + new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), + new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null), ]; $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); @@ -110,11 +110,11 @@ class ImportedLinksProcessorTest extends TestCase public function nonUniqueShortCodesAreAskedToUser(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), - new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'), - new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'), + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', 'foo'), + new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), + new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'), ]; $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); From 16873201e93b909dc7dbaa15089be0dfebbadf50 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 4 Feb 2021 21:27:16 +0100 Subject: [PATCH 052/115] Added support to search short URLs by title --- bin/test/run-api-tests.sh | 2 +- .../src/Repository/ShortUrlRepository.php | 1 + .../test-api/Action/ListShortUrlsTest.php | 23 +++++++++++-------- .../test-api/Fixtures/ShortUrlsFixture.php | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 06708d18..07b36881 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh export APP_ENV=test -export DB_DRIVER=mysql +export DB_DRIVER=postgres export TEST_ENV=api # Try to stop server just in case it hanged in last execution diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index d7ab2d66..3830cdaf 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -120,6 +120,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->andWhere($qb->expr()->orX( $qb->expr()->like('s.longUrl', ':searchPattern'), $qb->expr()->like('s.shortCode', ':searchPattern'), + $qb->expr()->like('s.title', ':searchPattern'), $qb->expr()->like('t.name', ':searchPattern'), $qb->expr()->like('d.authority', ':searchPattern'), )) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 2c7f35ae..f7182fe5 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -12,7 +12,7 @@ use function count; class ListShortUrlsTest extends ApiTestCase { - private const SHORT_URL_SHLINK = [ + private const SHORT_URL_SHLINK_WITH_TITLE = [ 'shortCode' => 'abc123', 'shortUrl' => 'http://doma.in/abc123', 'longUrl' => 'https://shlink.io', @@ -25,7 +25,7 @@ class ListShortUrlsTest extends ApiTestCase 'maxVisits' => null, ], 'domain' => null, - 'title' => 'Shlink', + 'title' => 'My cool title', ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', @@ -128,7 +128,7 @@ class ListShortUrlsTest extends ApiTestCase public function provideFilteredLists(): iterable { yield [[], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_META, @@ -136,7 +136,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_META, @@ -149,7 +149,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ self::SHORT_URL_DOCS, @@ -157,7 +157,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_META, @@ -165,12 +165,12 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, ], 'valid_api_key']; yield [['tags' => ['foo']], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_META, self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; @@ -178,17 +178,20 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; + yield [['searchTerm' => 'cool'], [ + self::SHORT_URL_SHLINK_WITH_TITLE, + ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [[], [ - self::SHORT_URL_SHLINK, + self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, ], 'author_api_key']; diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 426bc4f4..bfc65aa0 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -34,7 +34,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io', 'tags' => ['foo'], - 'title' => 'Shlink', + 'title' => 'My cool title', ]), $relationResolver), '2018-05-01', ); From 4330a09793ff227931851b53699e0f66102d2a33 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 4 Feb 2021 21:33:26 +0100 Subject: [PATCH 053/115] Removed use of deprecated approach for ordering in ListShort --- .../src/Command/ShortUrl/ListShortUrlsCommand.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 21beecaa..c0a99f02 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -21,8 +21,8 @@ use Symfony\Component\Console\Style\SymfonyStyle; use function array_flip; use function array_intersect_key; +use function array_pad; use function array_values; -use function count; use function explode; use function implode; use function sprintf; @@ -79,7 +79,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand 'order-by', 'o', InputOption::VALUE_REQUIRED, - 'The field from which we want to order by. Pass ASC or DESC separated by a comma.', + 'The field from which you want to order by. ' + . 'Define ordering dir by passing ASC or DESC after "," or "-".', ) ->addOptionWithDeprecatedFallback( 'show-tags', @@ -178,17 +179,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand return $result; } - /** - * @return array|string|null - */ - private function processOrderBy(InputInterface $input) + private function processOrderBy(InputInterface $input): ?string { $orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by'); if (empty($orderBy)) { return null; } - $orderBy = explode(',', $orderBy); - return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]]; + [$field, $dir] = array_pad(explode(',', $orderBy), 2, null); + return $dir === null ? $field : sprintf('%s-%s', $field, $dir); } } From ed18f10b94d53b52ce392d116638a221d56cf38c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 4 Feb 2021 22:07:54 +0100 Subject: [PATCH 054/115] Added support to order short URLs by title --- docs/swagger/paths/v1_short-urls.json | 4 +++- .../Command/ShortUrl/ListShortUrlsCommand.php | 17 +++++++++-------- .../Core/src/Repository/ShortUrlRepository.php | 4 +++- .../Rest/test-api/Action/ListShortUrlsTest.php | 8 ++++++++ 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index c3db1f05..b034dcf3 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -64,7 +64,9 @@ "dateCreated-ASC", "dateCreated-DESC", "visits-ASC", - "visits-DESC" + "visits-DESC", + "title-ASC", + "title-DESC" ] } }, diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index c0a99f02..24689bcb 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -19,11 +19,9 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function array_flip; -use function array_intersect_key; use function array_pad; -use function array_values; use function explode; +use function Functional\map; use function implode; use function sprintf; @@ -32,12 +30,16 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand use PagerfantaUtilsTrait; public const NAME = 'short-url:list'; - private const COLUMNS_WHITELIST = [ + private const COLUMNS_TO_SHOW = [ 'shortCode', + 'title', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', + ]; + private const COLUMNS_TO_SHOW_WITH_TAGS = [ + ...self::COLUMNS_TO_SHOW, 'tags', ]; @@ -154,21 +156,20 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand { $result = $this->shortUrlService->listShortUrls($params); - $headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count']; + $headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count']; if ($showTags) { $headers[] = 'Tags'; } $rows = []; foreach ($result as $row) { + $columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW; $shortUrl = $this->transformer->transform($row); if ($showTags) { $shortUrl['tags'] = implode(', ', $shortUrl['tags']); - } else { - unset($shortUrl['tags']); } - $rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST))); + $rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]); } ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage( diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 3830cdaf..f7a089b7 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -55,6 +55,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $fieldName = $orderBy->orderField(); $order = $orderBy->orderDirection(); + // visitsCount and visitCount are deprecated. Only visits should work if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) { $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') ->leftJoin('s.visits', 'v') @@ -66,10 +67,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // Map public field names to column names $fieldNameMap = [ - 'originalUrl' => 'longUrl', + 'originalUrl' => 'longUrl', // Deprecated 'longUrl' => 'longUrl', 'shortCode' => 'shortCode', 'dateCreated' => 'dateCreated', + 'title' => 'title', ]; if (array_key_exists($fieldName, $fieldNameMap)) { $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order); diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index f7182fe5..f81524ae 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -159,6 +159,14 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; + yield [['orderBy' => 'title-DESC'], [ + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, + ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, From 71e91a541fa24ff985857f53227c604391b37c71 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 4 Feb 2021 23:02:26 +0100 Subject: [PATCH 055/115] Allowed to resolve title during short URL edition if it has to --- module/Core/src/Entity/ShortUrl.php | 19 ++++++++----- module/Core/src/Model/ShortUrlEdit.php | 27 ++++++++++++++----- module/Core/src/Service/ShortUrlService.php | 15 +++++++++-- .../Core/test/Service/ShortUrlServiceTest.php | 2 +- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 53215a68..810281fa 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -171,24 +171,29 @@ class ShortUrl extends AbstractEntity ShortUrlEdit $shortUrlEdit, ?ShortUrlRelationResolverInterface $relationResolver = null ): void { - if ($shortUrlEdit->hasValidSince()) { + if ($shortUrlEdit->validSinceWasProvided()) { $this->validSince = $shortUrlEdit->validSince(); } - if ($shortUrlEdit->hasValidUntil()) { + if ($shortUrlEdit->validUntilWasProvided()) { $this->validUntil = $shortUrlEdit->validUntil(); } - if ($shortUrlEdit->hasMaxVisits()) { + if ($shortUrlEdit->maxVisitsWasProvided()) { $this->maxVisits = $shortUrlEdit->maxVisits(); } - if ($shortUrlEdit->hasLongUrl()) { - $this->longUrl = $shortUrlEdit->longUrl(); + if ($shortUrlEdit->longUrlWasProvided()) { + $this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl; } - if ($shortUrlEdit->hasTags()) { + if ($shortUrlEdit->tagsWereProvided()) { $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); } - if ($shortUrlEdit->hasTitle()) { + if ( + $this->title === null + || $shortUrlEdit->titleWasProvided() + || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) + ) { $this->title = $shortUrlEdit->title(); + $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); } } diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index ddb7d317..c2f97eed 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -27,6 +27,7 @@ final class ShortUrlEdit private array $tags = []; private bool $titlePropWasProvided = false; private ?string $title = null; + private bool $titleWasAutoResolved = false; private ?bool $validateUrl = null; private function __construct() @@ -74,7 +75,7 @@ final class ShortUrlEdit return $this->longUrl; } - public function hasLongUrl(): bool + public function longUrlWasProvided(): bool { return $this->longUrlPropWasProvided && $this->longUrl !== null; } @@ -84,7 +85,7 @@ final class ShortUrlEdit return $this->validSince; } - public function hasValidSince(): bool + public function validSinceWasProvided(): bool { return $this->validSincePropWasProvided; } @@ -94,7 +95,7 @@ final class ShortUrlEdit return $this->validUntil; } - public function hasValidUntil(): bool + public function validUntilWasProvided(): bool { return $this->validUntilPropWasProvided; } @@ -104,7 +105,7 @@ final class ShortUrlEdit return $this->maxVisits; } - public function hasMaxVisits(): bool + public function maxVisitsWasProvided(): bool { return $this->maxVisitsPropWasProvided; } @@ -117,7 +118,7 @@ final class ShortUrlEdit return $this->tags; } - public function hasTags(): bool + public function tagsWereProvided(): bool { return $this->tagsPropWasProvided; } @@ -127,11 +128,25 @@ final class ShortUrlEdit return $this->title; } - public function hasTitle(): bool + public function titleWasProvided(): bool { return $this->titlePropWasProvided; } + public function titleWasAutoResolved(): bool + { + return $this->titleWasAutoResolved; + } + + public function withResolvedTitle(string $title): self + { + $copy = clone $this; + $copy->title = $title; + $copy->titleWasAutoResolved = true; + + return $copy; + } + public function doValidateUrl(): ?bool { return $this->validateUrl; diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 70606219..e412b63b 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -61,8 +61,8 @@ class ShortUrlService implements ShortUrlServiceInterface ShortUrlEdit $shortUrlEdit, ?ApiKey $apiKey = null ): ShortUrl { - if ($shortUrlEdit->hasLongUrl()) { - $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); + if ($shortUrlEdit->longUrlWasProvided()) { + $shortUrlEdit = $this->processTitleAndValidateUrl($shortUrlEdit); } $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); @@ -72,4 +72,15 @@ class ShortUrlService implements ShortUrlServiceInterface return $shortUrl; } + + private function processTitleAndValidateUrl(ShortUrlEdit $shortUrlEdit): ShortUrlEdit + { + if ($shortUrlEdit->titleWasProvided()) { + $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); + return $shortUrlEdit; + } + + $title = $this->urlValidator->validateUrlWithTitle($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); + return $title === null ? $shortUrlEdit : $shortUrlEdit->withResolvedTitle($title); + } } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 178561f0..d4920f27 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -102,7 +102,7 @@ class ShortUrlServiceTest extends TestCase self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); $findShortUrl->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); - $this->urlValidator->validateUrl( + $this->urlValidator->validateUrlWithTitle( $shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl(), )->shouldHaveBeenCalledTimes($expectedValidateCalls); From 608742c2e2f12d7317c2229de96a5ccf64a782cf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 5 Feb 2021 17:59:34 +0100 Subject: [PATCH 056/115] Added helper service to avoid code duplication when resolving short URLs titles --- module/Core/config/dependencies.config.php | 6 ++- module/Core/src/Model/ShortUrlEdit.php | 13 ++++- module/Core/src/Model/ShortUrlMeta.php | 3 +- module/Core/src/Service/ShortUrlService.php | 22 +++------ module/Core/src/Service/UrlShortener.php | 22 +++------ .../Helper/ShortUrlTitleResolutionHelper.php | 28 +++++++++++ ...ShortUrlTitleResolutionHelperInterface.php | 15 ++++++ .../Helper/TitleResolutionModelInterface.php | 16 ++++++ .../Core/test/Service/ShortUrlServiceTest.php | 17 ++++--- module/Core/test/Service/UrlShortenerTest.php | 49 +++++-------------- .../ShortUrlTitleResolutionHelperTest.php | 49 +++++++++++++++++++ 11 files changed, 159 insertions(+), 81 deletions(-) create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php create mode 100644 module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php create mode 100644 module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 48166aeb..e742ad43 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -44,6 +44,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class, + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class, ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, @@ -69,7 +70,7 @@ return [ Options\UrlShortenerOptions::class => ['config.url_shortener'], Service\UrlShortener::class => [ - Util\UrlValidator::class, + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, Service\ShortUrl\ShortCodeHelper::class, @@ -82,7 +83,7 @@ return [ Service\ShortUrlService::class => [ 'em', Service\ShortUrl\ShortUrlResolver::class, - Util\UrlValidator::class, + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, ], Visit\VisitLocator::class => ['em'], @@ -122,6 +123,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index c2f97eed..3327aad4 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use function array_key_exists; @@ -13,7 +14,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\parseDateField; -final class ShortUrlEdit +final class ShortUrlEdit implements TitleResolutionModelInterface { private bool $longUrlPropWasProvided = false; private ?string $longUrl = null; @@ -75,6 +76,11 @@ final class ShortUrlEdit return $this->longUrl; } + public function getLongUrl(): string + { + return $this->longUrl() ?? ''; + } + public function longUrlWasProvided(): bool { return $this->longUrlPropWasProvided && $this->longUrl !== null; @@ -133,6 +139,11 @@ final class ShortUrlEdit return $this->titlePropWasProvided; } + public function hasTitle(): bool + { + return $this->titleWasProvided(); + } + public function titleWasAutoResolved(): bool { return $this->titleWasAutoResolved; diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index a741f78c..df25735c 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -15,7 +16,7 @@ use function Shlinkio\Shlink\Core\parseDateField; use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; -final class ShortUrlMeta +final class ShortUrlMeta implements TitleResolutionModelInterface { private string $longUrl; private ?Chronos $validSince = null; diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index e412b63b..dcb1d8cc 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -15,26 +15,26 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { private ORM\EntityManagerInterface $em; private ShortUrlResolverInterface $urlResolver; - private UrlValidatorInterface $urlValidator; + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private ShortUrlRelationResolverInterface $relationResolver; public function __construct( ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver, - UrlValidatorInterface $urlValidator, + ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, ShortUrlRelationResolverInterface $relationResolver ) { $this->em = $em; $this->urlResolver = $urlResolver; - $this->urlValidator = $urlValidator; + $this->titleResolutionHelper = $titleResolutionHelper; $this->relationResolver = $relationResolver; } @@ -62,7 +62,8 @@ class ShortUrlService implements ShortUrlServiceInterface ?ApiKey $apiKey = null ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { - $shortUrlEdit = $this->processTitleAndValidateUrl($shortUrlEdit); + /** @var ShortUrlEdit $shortUrlEdit */ + $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); } $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); @@ -72,15 +73,4 @@ class ShortUrlService implements ShortUrlServiceInterface return $shortUrl; } - - private function processTitleAndValidateUrl(ShortUrlEdit $shortUrlEdit): ShortUrlEdit - { - if ($shortUrlEdit->titleWasProvided()) { - $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); - return $shortUrlEdit; - } - - $title = $this->urlValidator->validateUrlWithTitle($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); - return $title === null ? $shortUrlEdit : $shortUrlEdit->withResolvedTitle($title); - } } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 6083e1f4..78064259 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -11,23 +11,23 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class UrlShortener implements UrlShortenerInterface { private EntityManagerInterface $em; - private UrlValidatorInterface $urlValidator; + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private ShortUrlRelationResolverInterface $relationResolver; private ShortCodeHelperInterface $shortCodeHelper; public function __construct( - UrlValidatorInterface $urlValidator, + ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, EntityManagerInterface $em, ShortUrlRelationResolverInterface $relationResolver, ShortCodeHelperInterface $shortCodeHelper ) { - $this->urlValidator = $urlValidator; + $this->titleResolutionHelper = $titleResolutionHelper; $this->em = $em; $this->relationResolver = $relationResolver; $this->shortCodeHelper = $shortCodeHelper; @@ -45,7 +45,8 @@ class UrlShortener implements UrlShortenerInterface return $existingShortUrl; } - $meta = $this->processTitleAndValidateUrl($meta); + /** @var ShortUrlMeta $meta */ + $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); return $this->em->transactional(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); @@ -82,15 +83,4 @@ class UrlShortener implements UrlShortenerInterface throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } } - - private function processTitleAndValidateUrl(ShortUrlMeta $meta): ShortUrlMeta - { - if ($meta->hasTitle()) { - $this->urlValidator->validateUrl($meta->getLongUrl(), $meta->doValidateUrl()); - return $meta; - } - - $title = $this->urlValidator->validateUrlWithTitle($meta->getLongUrl(), $meta->doValidateUrl()); - return $title === null ? $meta : $meta->withResolvedTitle($title); - } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php new file mode 100644 index 00000000..4615e45f --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -0,0 +1,28 @@ +urlValidator = $urlValidator; + } + + public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface + { + if ($data->hasTitle()) { + $this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl()); + return $data; + } + + $title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl()); + return $title === null ? $data : $data->withResolvedTitle($title); + } +} diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php new file mode 100644 index 00000000..50022746 --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -0,0 +1,15 @@ +em->flush()->willReturn(null); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); + $this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class); $this->service = new ShortUrlService( $this->em->reveal(), $this->urlResolver->reveal(), - $this->urlValidator->reveal(), + $this->titleResolutionHelper->reveal(), new SimpleShortUrlRelationResolver(), ); } @@ -93,6 +93,10 @@ class ShortUrlServiceTest extends TestCase )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); + $processTitle = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit)->willReturn( + $shortUrlEdit, + ); + $result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); self::assertSame($shortUrl, $result); @@ -102,10 +106,7 @@ class ShortUrlServiceTest extends TestCase self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); $findShortUrl->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); - $this->urlValidator->validateUrlWithTitle( - $shortUrlEdit->longUrl(), - $shortUrlEdit->doValidateUrl(), - )->shouldHaveBeenCalledTimes($expectedValidateCalls); + $processTitle->shouldHaveBeenCalledTimes($expectedValidateCalls); } public function provideShortUrlEdits(): iterable diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 9fbf82ed..7e319314 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -16,8 +16,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; use Shlinkio\Shlink\Core\Service\UrlShortener; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class UrlShortenerTest extends TestCase { @@ -25,12 +25,13 @@ class UrlShortenerTest extends TestCase private UrlShortener $urlShortener; private ObjectProphecy $em; - private ObjectProphecy $urlValidator; + private ObjectProphecy $titleResolutionHelper; private ObjectProphecy $shortCodeHelper; public function setUp(): void { - $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); + $this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class); + $this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->willReturnArgument(); $this->em = $this->prophesize(EntityManagerInterface::class); $this->em->persist(Argument::any())->will(function ($arguments): void { @@ -52,47 +53,22 @@ class UrlShortenerTest extends TestCase $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $this->urlShortener = new UrlShortener( - $this->urlValidator->reveal(), + $this->titleResolutionHelper->reveal(), $this->em->reveal(), new SimpleShortUrlRelationResolver(), $this->shortCodeHelper->reveal(), ); } - /** - * @test - * @dataProvider provideTitles - */ - public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void + /** @test */ + public function urlIsProperlyShortened(): void { $longUrl = 'http://foobar.com/12345/hello?foo=bar'; - $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData(['longUrl' => $longUrl, 'title' => $title])); + $meta = ShortUrlMeta::fromRawData(['longUrl' => $longUrl]); + $shortUrl = $this->urlShortener->shorten($meta); - self::assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); - $this->urlValidator->validateUrlWithTitle($longUrl, null)->shouldHaveBeenCalledTimes( - $validateWithTitleCallsNum, - ); - $this->urlValidator->validateUrl($longUrl, null)->shouldHaveBeenCalledTimes($validateCallsNum); - } - - /** - * @test - * @dataProvider provideTitles - */ - public function urlIsProperlyShortenedWithExpectedResolvedTitle(?string $title): void - { - $validateWithTitle = $this->urlValidator->validateUrlWithTitle(Argument::cetera())->willReturn($title); - - $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData(['longUrl' => 'foo'])); - - self::assertEquals($title, $shortUrl->getTitle()); - $validateWithTitle->shouldHaveBeenCalledOnce(); - } - - public function provideTitles(): iterable - { - yield 'no title' => [null, 1, 0]; - yield 'title' => ['link title', 0, 1]; + self::assertEquals($longUrl, $shortUrl->getLongUrl()); + $this->titleResolutionHelper->processTitleAndValidateUrl($meta)->shouldHaveBeenCalledOnce(); } /** @test */ @@ -123,8 +99,7 @@ class UrlShortenerTest extends TestCase $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->urlValidator->validateUrlWithTitle(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); self::assertSame($expected, $result); } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php new file mode 100644 index 00000000..6783303c --- /dev/null +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -0,0 +1,49 @@ +urlValidator = $this->prophesize(UrlValidatorInterface::class); + $this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator->reveal()); + } + + /** + * @test + * @dataProvider provideTitles + */ + public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void + { + $longUrl = 'http://foobar.com/12345/hello?foo=bar'; + $this->helper->processTitleAndValidateUrl( + ShortUrlMeta::fromRawData(['longUrl' => $longUrl, 'title' => $title]), + ); + + $this->urlValidator->validateUrlWithTitle($longUrl, null)->shouldHaveBeenCalledTimes( + $validateWithTitleCallsNum, + ); + $this->urlValidator->validateUrl($longUrl, null)->shouldHaveBeenCalledTimes($validateCallsNum); + } + + public function provideTitles(): iterable + { + yield 'no title' => [null, 1, 0]; + yield 'title' => ['link title', 0, 1]; + } +} From d386e1405c8d9a6d5593bb22ed674964c660f4f6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 5 Feb 2021 18:22:54 +0100 Subject: [PATCH 057/115] Ensure request is not performed if both title resolution and URL validation are disabled --- module/Core/src/Util/UrlValidator.php | 7 +++++-- module/Core/test/Util/UrlValidatorTest.php | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 8d05cbe6..62c2bea5 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -47,9 +47,12 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface public function validateUrlWithTitle(string $url, ?bool $doValidate): ?string { $doValidate = $doValidate ?? $this->options->isUrlValidationEnabled(); - $response = $this->validateUrlAndGetResponse($url, $doValidate); + if (! $doValidate && ! $this->options->autoResolveTitles()) { + return null; + } - if ($response === null || ! $this->options->autoResolveTitles()) { + $response = $this->validateUrlAndGetResponse($url, $doValidate); + if ($response === null) { return null; } diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 7c5f7c55..9ef8e94e 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -87,6 +87,7 @@ class UrlValidatorTest extends TestCase ): void { $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); $this->options->validateUrl = $validateUrl; + $this->options->autoResolveTitles = true; $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', $doValidate); @@ -107,10 +108,10 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); $this->options->autoResolveTitles = false; - $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); self::assertNull($result); - $request->shouldHaveBeenCalledOnce(); + $request->shouldNotHaveBeenCalled(); } /** @test */ From bc632fd644ffb13e3a667494ee8900ce0acac4b9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 5 Feb 2021 18:26:22 +0100 Subject: [PATCH 058/115] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6079f750..87f8b9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. +* [#941](https://github.com/shlinkio/shlink/issues/856) Added support to provide a title for every short URL. + + The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in. ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. From de4e677f18a0a6bb804b332607cda709ae66bfc8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 5 Feb 2021 18:33:36 +0100 Subject: [PATCH 059/115] Fixed database started for API tests in GitHub workflow --- .github/workflows/ci.yml | 2 +- config/autoload/url-shortener.global.php | 2 +- docker/config/shlink_in_docker.local.php | 2 +- module/Core/src/Options/UrlShortenerOptions.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b591afbd..0674cd2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,7 +189,7 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - name: Use PHP uses: shivammathur/setup-php@v2 with: diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index f4a7966a..015d459e 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -19,7 +19,7 @@ return [ 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, - 'auto_resolve_titles' => false, // Deprecated value. Default to true with Shlink 3.0.0 + 'auto_resolve_titles' => false, ], ]; diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 070f0974..40173d69 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -125,7 +125,7 @@ return [ 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), - 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), // Deprecated value. Default to true + 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 553a160f..ebedbf97 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -18,7 +18,7 @@ class UrlShortenerOptions extends AbstractOptions private bool $validateUrl = true; private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; - private bool $autoResolveTitles = false; // Deprecated value. Default to true with Shlink 3.0.0 + private bool $autoResolveTitles = false; public function isUrlValidationEnabled(): bool { From 37f4d18d34ef753f2baee59df4397c2ced571c28 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Feb 2021 20:45:45 +0100 Subject: [PATCH 060/115] Updated to shlink-importer v2.2 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3f314a2c..29068f06 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-common": "dev-main#b889f5d as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", - "shlinkio/shlink-importer": "dev-main#b6fc81f as 2.2", + "shlinkio/shlink-importer": "^2.2", "shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", From 7121ff340aa6032c651ac9f72fdf0dca0d477ead Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Feb 2021 20:47:26 +0100 Subject: [PATCH 061/115] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f8b9d6..75a84d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in. +* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file. + + The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. + ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. * [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. From da65c05c4f2f97a5c5fadbeb922227fff1866aa9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Feb 2021 21:37:58 +0100 Subject: [PATCH 062/115] Added double check when parsing build epoch from the GeoLite db file in case it is not an integer --- .../GeolocationDbUpdateFailedException.php | 34 +++++++++++-- module/CLI/src/Util/GeolocationDbUpdater.php | 32 +++++++++++-- .../Command/Visit/LocateVisitsCommandTest.php | 4 +- ...GeolocationDbUpdateFailedExceptionTest.php | 48 +++++++++++++++---- .../LocateShortUrlVisitTest.php | 4 +- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index 8ddee216..f663fd8f 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -7,18 +7,46 @@ namespace Shlinkio\Shlink\CLI\Exception; use RuntimeException; use Throwable; +use function sprintf; + class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface { private bool $olderDbExists; - public static function create(bool $olderDbExists, ?Throwable $prev = null): self + public static function withOlderDb(?Throwable $prev = null): self { $e = new self( - 'An error occurred while updating geolocation database, and an older version could not be found', + 'An error occurred while updating geolocation database, but an older DB is already present.', 0, $prev, ); - $e->olderDbExists = $olderDbExists; + $e->olderDbExists = true; + + return $e; + } + + public static function withoutOlderDb(?Throwable $prev = null): self + { + $e = new self( + 'An error occurred while updating geolocation database, and an older version could not be found.', + 0, + $prev, + ); + $e->olderDbExists = false; + + return $e; + } + + /** + * @param mixed $buildEpoch + */ + public static function withInvalidEpochInOldDb($buildEpoch): self + { + $e = new self(sprintf( + 'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.', + $buildEpoch, + )); + $e->olderDbExists = true; return $e; } diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index fd40fc15..b8f5b756 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -6,11 +6,14 @@ namespace Shlinkio\Shlink\CLI\Util; use Cake\Chronos\Chronos; use GeoIp2\Database\Reader; +use MaxMind\Db\Reader\Metadata; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Symfony\Component\Lock\LockFactory; +use function is_int; + class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { private const LOCK_NAME = 'geolocation-db-update'; @@ -52,7 +55,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface } $meta = $this->geoLiteDbReader->metadata(); - if ($this->buildIsTooOld($meta->buildEpoch)) { + if ($this->buildIsTooOld($meta)) { $this->downloadNewDb(true, $mustBeUpdated, $handleProgress); } } @@ -69,14 +72,37 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface try { $this->dbUpdater->downloadFreshCopy($handleProgress); } catch (RuntimeException $e) { - throw GeolocationDbUpdateFailedException::create($olderDbExists, $e); + throw $olderDbExists + ? GeolocationDbUpdateFailedException::withOlderDb($e) + : GeolocationDbUpdateFailedException::withoutOlderDb($e); } } - private function buildIsTooOld(int $buildTimestamp): bool + private function buildIsTooOld(Metadata $meta): bool { + $buildTimestamp = $this->resolveBuildTimestamp($meta); $buildDate = Chronos::createFromTimestamp($buildTimestamp); $now = Chronos::now(); + return $now->gt($buildDate->addDays(35)); } + + private function resolveBuildTimestamp(Metadata $meta): int + { + // In theory the buildEpoch should be an int, but it has been reported to come as a string. + // See https://github.com/shlinkio/shlink/issues/1002 for context + + /** @var int|string $buildEpoch */ + $buildEpoch = $meta->buildEpoch; + if (is_int($buildEpoch)) { + return $buildEpoch; + } + + $intBuildEpoch = (int) $buildEpoch; + if ($buildEpoch === (string) $intBuildEpoch) { + return $intBuildEpoch; + } + + throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch); + } } diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index fc64d643..5ba0778a 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -217,7 +217,9 @@ class LocateVisitsCommandTest extends TestCase $mustBeUpdated($olderDbExists); $handleProgress(100, 50); - throw GeolocationDbUpdateFailedException::create($olderDbExists); + throw $olderDbExists + ? GeolocationDbUpdateFailedException::withOlderDb() + : GeolocationDbUpdateFailedException::withoutOlderDb(); }, ); diff --git a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php index 33d7d76e..470aed2c 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -14,26 +14,54 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase { /** * @test - * @dataProvider provideCreateArgs + * @dataProvider providePrev */ - public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void + public function withOlderDbBuildsException(?Throwable $prev): void { - $e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev); + $e = GeolocationDbUpdateFailedException::withOlderDb($prev); - self::assertEquals($olderDbExists, $e->olderDbExists()); + self::assertTrue($e->olderDbExists()); self::assertEquals( - 'An error occurred while updating geolocation database, and an older version could not be found', + 'An error occurred while updating geolocation database, but an older DB is already present.', $e->getMessage(), ); self::assertEquals(0, $e->getCode()); self::assertEquals($prev, $e->getPrevious()); } - public function provideCreateArgs(): iterable + /** + * @test + * @dataProvider providePrev + */ + public function withoutOlderDbBuildsException(?Throwable $prev): void { - yield 'older DB and no prev' => [true, null]; - yield 'older DB and prev' => [true, new RuntimeException('prev')]; - yield 'no older DB and no prev' => [false, null]; - yield 'no older DB and prev' => [false, new Exception('prev')]; + $e = GeolocationDbUpdateFailedException::withoutOlderDb($prev); + + self::assertFalse($e->olderDbExists()); + self::assertEquals( + 'An error occurred while updating geolocation database, and an older version could not be found.', + $e->getMessage(), + ); + self::assertEquals(0, $e->getCode()); + self::assertEquals($prev, $e->getPrevious()); + } + + public function providePrev(): iterable + { + yield 'no prev' => [null]; + yield 'RuntimeException' => [new RuntimeException('prev')]; + yield 'Exception' => [new Exception('prev')]; + } + + /** @test */ + public function withInvalidEpochInOldDbBuildsException(): void + { + $e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar'); + + self::assertTrue($e->olderDbExists()); + self::assertEquals( + 'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.', + $e->getMessage(), + ); } } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index fdb5bfec..4d348528 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -169,7 +169,7 @@ class LocateShortUrlVisitTest extends TestCase /** @test */ public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void { - $e = GeolocationDbUpdateFailedException::create(true); + $e = GeolocationDbUpdateFailedException::withOlderDb(); $ipAddr = '1.2.3.0'; $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); @@ -200,7 +200,7 @@ class LocateShortUrlVisitTest extends TestCase /** @test */ public function errorWhenDownloadingGeoLiteCancelsLocation(): void { - $e = GeolocationDbUpdateFailedException::create(false); + $e = GeolocationDbUpdateFailedException::withoutOlderDb(); $ipAddr = '1.2.3.0'; $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); From 919b567d462fc861892a47768f1b6c7e026c627a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Feb 2021 21:49:49 +0100 Subject: [PATCH 063/115] Added tests covering new logic to parse GeolLite 2 build epoch param --- .../test/Util/GeolocationDbUpdaterTest.php | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/Util/GeolocationDbUpdaterTest.php index 71e05d8a..54b07f1f 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/Util/GeolocationDbUpdaterTest.php @@ -80,17 +80,9 @@ class GeolocationDbUpdaterTest extends TestCase public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void { $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); - $getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([ - 'binary_format_major_version' => '', - 'binary_format_minor_version' => '', - 'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(), - 'database_type' => '', - 'languages' => '', - 'description' => '', - 'ip_version' => '', - 'node_count' => 1, - 'record_size' => 4, - ])); + $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch( + Chronos::now()->subDays($days)->getTimestamp(), + )); $prev = new RuntimeException(''); $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev); @@ -120,21 +112,12 @@ class GeolocationDbUpdaterTest extends TestCase /** * @test * @dataProvider provideSmallDays + * @param string|int $buildEpoch */ - public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void + public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void { $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); - $getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([ - 'binary_format_major_version' => '', - 'binary_format_minor_version' => '', - 'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(), - 'database_type' => '', - 'languages' => '', - 'description' => '', - 'ip_version' => '', - 'node_count' => 1, - 'record_size' => 4, - ])); + $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch)); $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void { }); @@ -147,6 +130,48 @@ class GeolocationDbUpdaterTest extends TestCase public function provideSmallDays(): iterable { - return map(range(0, 34), fn (int $days) => [$days]); + $generateParamsWithTimestamp = static function (int $days) { + $timestamp = Chronos::now()->subDays($days)->getTimestamp(); + return [$days % 2 === 0 ? $timestamp : (string) $timestamp]; + }; + + return map(range(0, 34), $generateParamsWithTimestamp); + } + + /** @test */ + public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void + { + $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); + $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid')); + $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void { + }); + + $this->expectException(GeolocationDbUpdateFailedException::class); + $this->expectExceptionMessage( + 'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.', + ); + $fileExists->shouldBeCalledOnce(); + $getMeta->shouldBeCalledOnce(); + $download->shouldNotBeCalled(); + + $this->geolocationDbUpdater->checkDbUpdate(); + } + + /** + * @param string|int $buildEpoch + */ + private function buildMetaWithBuildEpoch($buildEpoch): Metadata + { + return new Metadata([ + 'binary_format_major_version' => '', + 'binary_format_minor_version' => '', + 'build_epoch' => $buildEpoch, + 'database_type' => '', + 'languages' => '', + 'description' => '', + 'ip_version' => '', + 'node_count' => 1, + 'record_size' => 4, + ]); } } From 1e2b88496cef7f4ea64f6ed72db4b29c5d1acbc2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Feb 2021 21:51:05 +0100 Subject: [PATCH 064/115] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a84d8c..d304c548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. * [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used. +* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int. ## [2.5.2] - 2021-01-24 From 383fde488be595e060bc94b5fe04818ceafb7a86 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 08:32:12 +0100 Subject: [PATCH 065/115] Added support to define the margin when generating the QR codes --- module/Core/src/Action/QrCodeAction.php | 26 +++++++++++++++----- module/Core/test/Action/QrCodeActionTest.php | 15 +++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index b39159fd..3209d651 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -50,12 +50,9 @@ class QrCodeAction implements MiddlewareInterface } $query = $request->getQueryParams(); - // Size attribute is deprecated - $size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE)); - $qrCode = new QrCode($this->stringifier->stringify($shortUrl)); - $qrCode->setSize($size); - $qrCode->setMargin(0); + $qrCode->setSize($this->resolveSize($request, $query)); + $qrCode->setMargin($this->resolveMargin($query)); $format = $query['format'] ?? 'png'; if ($format === 'svg') { @@ -65,12 +62,29 @@ class QrCodeAction implements MiddlewareInterface return new QrCodeResponse($qrCode); } - private function normalizeSize(int $size): int + private function resolveSize(Request $request, array $query): int { + // Size attribute is deprecated. After v3.0.0, always use the query param instead + $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE); if ($size < self::MIN_SIZE) { return self::MIN_SIZE; } return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; } + + private function resolveMargin(array $query): int + { + if (! isset($query['margin'])) { + return 0; + } + + $margin = $query['margin']; + $intMargin = (int) $margin; + if ($margin !== (string) $intMargin) { + return 0; + } + + return $intMargin < 0 ? 0 : $intMargin; + } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 245ac6de..aeaec13f 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -133,5 +133,20 @@ class QrCodeActionTest extends TestCase ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']), 350, ]; + yield 'margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370]; + yield 'margin and size' => [ + ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']), + 400, + ]; + yield 'negative margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300]; + yield 'non-numeric margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']), 300]; + yield 'negative margin and size' => [ + ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']), + 150, + ]; + yield 'non-numeric margin and size' => [ + ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']), + 538, + ]; } } From 9f512705fadd910e85b628036dd4dbbae7467943 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 08:35:52 +0100 Subject: [PATCH 066/115] Documented margin param on QR code endpoint --- CHANGELOG.md | 4 +++- docs/swagger/paths/{shortCode}_qr-code.json | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d304c548..8b34f679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. +* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code. + ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. * [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. @@ -24,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0 -* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`). +* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`). The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead. diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 3714f802..00502ad5 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -40,6 +40,17 @@ "svg" ] } + }, + { + "name": "margin", + "in": "query", + "description": "The margin around the QR code image.", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } } ], "responses": { From 2289eebd91e7830878ee3081c593ebc5ed1dff92 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 09:24:01 +0100 Subject: [PATCH 067/115] Updated docker images to PHP 8.0.2 --- Dockerfile | 2 +- data/infra/php.Dockerfile | 2 +- data/infra/swoole.Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d215d1f6..fd03c6f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.0.1-alpine3.13 as base +FROM php:8.0.2-alpine3.13 as base ARG SHLINK_VERSION=2.5.2 ENV SHLINK_VERSION ${SHLINK_VERSION} diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 671224b8..dc4930ec 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.0.1-fpm-alpine3.13 +FROM php:8.0.2-fpm-alpine3.13 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.19 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 58f9a5fa..eefa6827 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.0.1-alpine3.13 +FROM php:8.0.2-alpine3.13 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.19 From 1c492881e1689477ccbfd0850017acd379cb09da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 22:55:30 +0100 Subject: [PATCH 068/115] Updated to swoole 4.6.3 --- .github/workflows/ci.yml | 20 ++++++++++---------- .github/workflows/publish-release.yml | 2 +- Dockerfile | 2 +- data/infra/swoole.Dockerfile | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0674cd2b..23b76317 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer cs @@ -39,7 +39,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer stan @@ -57,7 +57,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -83,7 +83,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -111,7 +111,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:mysql @@ -131,7 +131,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:maria @@ -151,7 +151,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:postgres @@ -173,7 +173,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2, pdo_sqlsrv-5.9.0 + extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0 coverage: none - run: composer install --no-interaction --prefer-dist - name: Create test database @@ -195,7 +195,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -225,7 +225,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 565311ed..3047b4ee 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: with: php-version: '7.4' # Publish release with lowest supported PHP version tools: composer - extensions: swoole-4.6.2 + extensions: swoole-4.6.3 - name: Generate release assets run: ./build.sh ${GITHUB_REF#refs/tags/v} - name: Publish release with assets diff --git a/Dockerfile b/Dockerfile index fd03c6f1..fd703ebc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM php:8.0.2-alpine3.13 as base ARG SHLINK_VERSION=2.5.2 ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.6.2 +ENV SWOOLE_VERSION 4.6.3 ENV PDO_SQLSRV_VERSION 5.9.0 ENV LC_ALL "C" diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index eefa6827..7cbfacb0 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -4,7 +4,7 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.19 ENV PDO_SQLSRV_VERSION 5.9.0 ENV INOTIFY_VERSION 3.0.0 -ENV SWOOLE_VERSION 4.6.2 +ENV SWOOLE_VERSION 4.6.3 RUN apk update From a1fb44f2a61d37863b02108c8255f47d6b28f1dd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 10:56:22 +0100 Subject: [PATCH 069/115] Added ADR for not-found visits tracking --- ...e-url-invalid-short-url-and-regular-404.md | 33 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 34 insertions(+) create mode 100644 docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md diff --git a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md new file mode 100644 index 00000000..032820ef --- /dev/null +++ b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md @@ -0,0 +1,33 @@ +# Track visits to 'base_url', 'invalid_short_url' and 'regular_404' + +* Status: Accepted +* Date: 2021-02-07 + +## Context and problem statement + +Shlink has the mechanism to return either custom errors or custom redirects when visiting the instance's base URL, an invalid short URL, or any other kind of URL that would result in a "Not found" error. + +However, it does not track visits to any of those, just to valid short URLs. + +The intention is to change that, and allow users to track the cases mentioned above. + +## Considered option + +* Create a new table to track visits o this kind. +* Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields. + +## Decision outcome + +*TBD* + +## Pros and Cons of the Options + +### New table + +* Good because we don't touch existing models and tables, reducing the risk to introduce a backwards compatibility break. +* Bad because we will have to repeat data modeling and logic, or refactor some components to support both contexts. This in turn increases the options to introduce a BC break. + +### Reuse existing table + +* Good because all the mechanisms in place to handle visits will work out of the box, including locating visits and such. +* Bad because we will have more optional properties, which means more double checks in many places. diff --git a/docs/adr/README.md b/docs/adr/README.md index 56df328f..93d82cff 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,4 +2,5 @@ Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome. +* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md) * [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md) From 23cffce861fbd418c9ecaae30c0b5b5afda54885 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 11:02:50 +0100 Subject: [PATCH 070/115] Updated Visit entity so that the short URL is nullable --- module/Core/src/Entity/Visit.php | 11 ++--------- .../src/EventDispatcher/NotifyVisitToWebHooks.php | 4 ++-- module/Core/src/Mercure/MercureUpdatesGenerator.php | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 7e6ed060..c2b79a04 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -18,7 +18,7 @@ class Visit extends AbstractEntity implements JsonSerializable private Chronos $date; private ?string $remoteAddr = null; private string $userAgent; - private ShortUrl $shortUrl; + private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null) @@ -54,7 +54,7 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } - public function getShortUrl(): ShortUrl + public function getShortUrl(): ?ShortUrl { return $this->shortUrl; } @@ -75,13 +75,6 @@ class Visit extends AbstractEntity implements JsonSerializable return $this; } - /** - * Specify data which should be serialized to JSON - * @link http://php.net/manual/en/jsonserializable.jsonserialize.php - * @return array data which can be serialized by json_encode, - * which is a value of any type other than a resource. - * @since 5.4.0 - */ public function jsonSerialize(): array { return [ diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index d3b27602..b236a1c1 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -10,6 +10,7 @@ use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Promise\Promise; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Promise\Utils; use GuzzleHttp\RequestOptions; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; @@ -20,7 +21,6 @@ use Throwable; use function Functional\map; use function Functional\partial_left; -use function GuzzleHttp\Promise\settle; class NotifyVisitToWebHooks { @@ -69,7 +69,7 @@ class NotifyVisitToWebHooks $requestPromises = $this->performRequests($requestOptions, $visitId); // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error. - settle($requestPromises)->wait(); + Utils::settle($requestPromises)->wait(); } private function buildRequestOptions(Visit $visit): array diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index bd00e836..9a0a28f3 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -38,7 +38,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode()); return new Update($topic, $this->serialize([ - 'shortUrl' => $this->transformer->transform($visit->getShortUrl()), + 'shortUrl' => $this->transformer->transform($shortUrl), 'visit' => $visit, ])); } From f5666c945138d6275a29f5c70a178cd0d1ffec2a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 11:26:01 +0100 Subject: [PATCH 071/115] Added new columns for extra tracking in visits table --- data/migrations/Version20210207100807.php | 53 +++++++++++++++++++ .../Shlinkio.Shlink.Core.Entity.Visit.php | 13 ++++- module/Core/src/Entity/Visit.php | 8 +++ module/Core/src/Model/Visitor.php | 1 + 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 data/migrations/Version20210207100807.php diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php new file mode 100644 index 00000000..a74c0b08 --- /dev/null +++ b/data/migrations/Version20210207100807.php @@ -0,0 +1,53 @@ +getTable('visits'); + $shortUrlId = $visits->getColumn('short_url_id'); + + $this->skipIf(! $shortUrlId->getNotnull()); + + $shortUrlId->setNotnull(false); + + $visits->addColumn('visited_url', Types::STRING, [ + 'length' => Visitor::VISITED_URL_MAX_LENGTH, + 'notnull' => false, + ]); + $visits->addColumn('type', Types::STRING, [ + 'length' => 255, + 'default' => Visit::TYPE_VALID_SHORT_URL, + ]); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $shortUrlId = $visits->getColumn('short_url_id'); + + $this->skipIf($shortUrlId->getNotnull()); + + $shortUrlId->setNotnull(true); + $visits->dropColumn('visited_url'); + $visits->dropColumn('type'); + } + + /** + * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 + */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index 5143389b..efcccb65 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -47,11 +47,22 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->createManyToOne('shortUrl', Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') ->build(); $builder->createManyToOne('visitLocation', Entity\VisitLocation::class) ->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL') ->cascadePersist() ->build(); + + $builder->createField('visitedUrl', Types::STRING) + ->columnName('visited_url') + ->length(Visitor::VISITED_URL_MAX_LENGTH) + ->nullable() + ->build(); + + $builder->createField('type', Types::STRING) + ->columnName('type') + ->length(255) + ->build(); }; diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index c2b79a04..5b1b91d3 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -14,10 +14,17 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; class Visit extends AbstractEntity implements JsonSerializable { + public const TYPE_VALID_SHORT_URL = 'valid_short_url'; + public const TYPE_INVALID_SHORT_URL = 'invalid_short_url'; + public const TYPE_BASE_URL = 'base_url'; + public const TYPE_REGULAR_404 = 'regular_404'; + private string $referer; private Chronos $date; private ?string $remoteAddr = null; + private ?string $visitedUrl = null; private string $userAgent; + private string $type; private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; @@ -28,6 +35,7 @@ class Visit extends AbstractEntity implements JsonSerializable $this->userAgent = $visitor->getUserAgent(); $this->referer = $visitor->getReferer(); $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); + $this->type = self::TYPE_VALID_SHORT_URL; } private function processAddress(bool $anonymize, ?string $address): ?string diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index 8c24ab26..f973be49 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -14,6 +14,7 @@ final class Visitor public const USER_AGENT_MAX_LENGTH = 512; public const REFERER_MAX_LENGTH = 1024; public const REMOTE_ADDRESS_MAX_LENGTH = 256; + public const VISITED_URL_MAX_LENGTH = 2048; private string $userAgent; private string $referer; From 12b07bb0ac977023b562c6ef90cfb1e607a19f4d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 7 Feb 2021 21:31:12 +0100 Subject: [PATCH 072/115] Created named constructors for Visit entity and added tracking of the visited URL --- .../Command/ShortUrl/GetVisitsCommandTest.php | 2 +- .../Command/Visit/LocateVisitsCommandTest.php | 6 ++-- module/Core/src/Entity/Visit.php | 36 ++++++++++++++++--- module/Core/src/Model/Visitor.php | 12 +++++-- module/Core/src/Service/VisitsTracker.php | 2 +- .../Repository/ShortUrlRepositoryTest.php | 2 +- .../test-db/Repository/TagRepositoryTest.php | 8 ++--- .../Repository/VisitRepositoryTest.php | 2 +- module/Core/test/Entity/VisitTest.php | 24 +++++-------- .../LocateShortUrlVisitTest.php | 14 ++++---- .../NotifyVisitToMercureTest.php | 4 +-- .../NotifyVisitToWebHooksTest.php | 2 +- .../Mercure/MercureUpdatesGeneratorTest.php | 2 +- module/Core/test/Model/VisitorTest.php | 5 +-- .../ShortUrl/DeleteShortUrlServiceTest.php | 2 +- .../Service/ShortUrl/ShortUrlResolverTest.php | 4 +-- .../Core/test/Service/VisitsTrackerTest.php | 4 +-- module/Core/test/Visit/VisitLocatorTest.php | 5 +-- .../Rest/test-api/Fixtures/VisitsFixture.php | 25 +++++++++---- 19 files changed, 101 insertions(+), 60 deletions(-) diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index 3da492e3..4d5e5832 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -103,7 +103,7 @@ class GetVisitsCommandTest extends TestCase $shortCode = 'abc123'; $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( new Paginator(new ArrayAdapter([ - (new Visit(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '')))->locate( + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')), ), ])), diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 5ba0778a..236fac50 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -77,7 +77,7 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args ): void { - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $location = new VisitLocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); @@ -121,7 +121,7 @@ class LocateVisitsCommandTest extends TestCase */ public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void { - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $address)); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, '')); $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( @@ -154,7 +154,7 @@ class LocateVisitsCommandTest extends TestCase /** @test */ public function errorWhileLocatingIpIsDisplayed(): void { - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 5b1b91d3..d61e8af6 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -21,21 +21,27 @@ class Visit extends AbstractEntity implements JsonSerializable private string $referer; private Chronos $date; - private ?string $remoteAddr = null; - private ?string $visitedUrl = null; + private ?string $remoteAddr; + private ?string $visitedUrl; private string $userAgent; private string $type; private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; - public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null) - { + public function __construct( + ?ShortUrl $shortUrl, + Visitor $visitor, + bool $anonymize = true, + ?Chronos $date = null, + string $type = self::TYPE_VALID_SHORT_URL + ) { $this->shortUrl = $shortUrl; $this->date = $date ?? Chronos::now(); $this->userAgent = $visitor->getUserAgent(); $this->referer = $visitor->getReferer(); $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); - $this->type = self::TYPE_VALID_SHORT_URL; + $this->visitedUrl = $visitor->getVisitedUrl(); + $this->type = $type; } private function processAddress(bool $anonymize, ?string $address): ?string @@ -52,6 +58,26 @@ class Visit extends AbstractEntity implements JsonSerializable } } + public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self + { + return new self($shortUrl, $visitor, $anonymize); + } + + public static function forBasePath(Visitor $visitor, bool $anonymize = true): self + { + return new self(null, $visitor, $anonymize, null, self::TYPE_BASE_URL); + } + + public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self + { + return new self(null, $visitor, $anonymize, null, self::TYPE_INVALID_SHORT_URL); + } + + public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self + { + return new self(null, $visitor, $anonymize, null, self::TYPE_REGULAR_404); + } + public function getRemoteAddr(): ?string { return $this->remoteAddr; diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index f973be49..7438bdce 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -18,12 +18,14 @@ final class Visitor private string $userAgent; private string $referer; + private string $visitedUrl; private ?string $remoteAddress; - public function __construct(string $userAgent, string $referer, ?string $remoteAddress) + public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) { $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH); $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); + $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH); $this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH); } @@ -38,12 +40,13 @@ final class Visitor $request->getHeaderLine('User-Agent'), $request->getHeaderLine('Referer'), $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR), + $request->getUri()->__toString(), ); } public static function emptyInstance(): self { - return new self('', '', null); + return new self('', '', null, ''); } public function getUserAgent(): string @@ -60,4 +63,9 @@ final class Visitor { return $this->remoteAddress; } + + public function getVisitedUrl(): string + { + return $this->visitedUrl; + } } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index a8362f7c..bda33a01 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -41,7 +41,7 @@ class VisitsTracker implements VisitsTrackerInterface public function track(ShortUrl $shortUrl, Visitor $visitor): void { - $visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr); + $visit = Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr); $this->em->persist($visit); $this->em->flush(); diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 29694867..48381857 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -95,7 +95,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($foo); $bar = ShortUrl::withLongUrl('bar'); - $visit = new Visit($bar, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($bar, Visitor::emptyInstance()); $this->getEntityManager()->persist($visit); $bar->setVisits(new ArrayCollection([$visit])); $this->getEntityManager()->persist($bar); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 0a91775b..34a06a40 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -64,13 +64,13 @@ class TagRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags), $this->relationResolver); $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())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); - $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); $this->getEntityManager()->flush(); $result = $this->repo->findTagsWithInfo(); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 740edff5..5681ee26 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -52,7 +52,7 @@ class VisitRepositoryTest extends DatabaseTestCase }; for ($i = 0; $i < 6; $i++) { - $visit = new Visit($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); if ($i >= 2) { $location = new VisitLocation(Location::emptyInstance()); diff --git a/module/Core/test/Entity/VisitTest.php b/module/Core/test/Entity/VisitTest.php index d583c799..7be3c3fc 100644 --- a/module/Core/test/Entity/VisitTest.php +++ b/module/Core/test/Entity/VisitTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Entity; -use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -13,35 +12,30 @@ use Shlinkio\Shlink\Core\Model\Visitor; class VisitTest extends TestCase { - /** - * @test - * @dataProvider provideDates - */ - public function isProperlyJsonSerialized(?Chronos $date): void + /** @test */ + public function isProperlyJsonSerialized(): void { - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4', '')); self::assertEquals([ 'referer' => 'some site', - 'date' => ($date ?? $visit->getDate())->toAtomString(), + 'date' => $visit->getDate()->toAtomString(), 'userAgent' => 'Chrome', 'visitLocation' => null, ], $visit->jsonSerialize()); } - public function provideDates(): iterable - { - yield 'null date' => [null]; - yield 'not null date' => [Chronos::now()->subDays(10)]; - } - /** * @test * @dataProvider provideAddresses */ public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void { - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', $address), $anonymize); + $visit = Visit::forValidShortUrl( + ShortUrl::createEmpty(), + new Visitor('Chrome', 'some site', $address, ''), + $anonymize, + ); self::assertEquals($expectedAddress, $visit->getRemoteAddr()); } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 4d348528..03eef5f6 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -78,7 +78,7 @@ class LocateShortUrlVisitTest extends TestCase { $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn( - new Visit(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4')), + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), ); $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow( WrongIpException::class, @@ -127,9 +127,9 @@ class LocateShortUrlVisitTest extends TestCase { $shortUrl = ShortUrl::createEmpty(); - yield 'null IP' => [new Visit($shortUrl, new Visitor('', '', null))]; - yield 'empty IP' => [new Visit($shortUrl, new Visitor('', '', ''))]; - yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))]; + yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; + yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; + yield 'localhost' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', IpAddress::LOCALHOST, ''))]; } /** @@ -139,7 +139,7 @@ class LocateShortUrlVisitTest extends TestCase public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void { $ipAddr = $originalIpAddress ?? $anonymizedIpAddress; - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123', $originalIpAddress); @@ -171,7 +171,7 @@ class LocateShortUrlVisitTest extends TestCase { $e = GeolocationDbUpdateFailedException::withOlderDb(); $ipAddr = '1.2.3.0'; - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123'); @@ -202,7 +202,7 @@ class LocateShortUrlVisitTest extends TestCase { $e = GeolocationDbUpdateFailedException::withoutOlderDb(); $ipAddr = '1.2.3.0'; - $visit = new Visit(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr)); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new ShortUrlVisited('123'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index f5b525ea..1180d05c 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -77,7 +77,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $update = new Update('', ''); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); @@ -101,7 +101,7 @@ class NotifyVisitToMercureTest extends TestCase public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $update = new Update('', ''); $e = new RuntimeException('Error'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 9599c2c8..fcd97d2d 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -82,7 +82,7 @@ class NotifyVisitToWebHooksTest extends TestCase $invalidWebhooks = ['invalid', 'baz']; $find = $this->em->find(Visit::class, '1')->willReturn( - new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()), ); $requestAsync = $this->httpClient->requestAsync( RequestMethodInterface::METHOD_POST, diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index 435fb4d8..9e4b418e 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -35,7 +35,7 @@ class MercureUpdatesGeneratorTest extends TestCase 'longUrl' => '', 'title' => $title, ])); - $visit = new Visit($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); $update = $this->generator->{$method}($visit); diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index d52a6389..e1003056 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -31,7 +31,7 @@ class VisitorTest extends TestCase public function provideParams(): iterable { yield 'all values are bigger' => [ - [str_repeat('a', 1000), str_repeat('b', 2000), str_repeat('c', 500)], + [str_repeat('a', 1000), str_repeat('b', 2000), str_repeat('c', 500), ''], [ 'userAgent' => str_repeat('a', Visitor::USER_AGENT_MAX_LENGTH), 'referer' => str_repeat('b', Visitor::REFERER_MAX_LENGTH), @@ -39,7 +39,7 @@ class VisitorTest extends TestCase ], ]; yield 'some values are smaller' => [ - [str_repeat('a', 10), str_repeat('b', 2000), null], + [str_repeat('a', 10), str_repeat('b', 2000), null, ''], [ 'userAgent' => str_repeat('a', 10), 'referer' => str_repeat('b', Visitor::REFERER_MAX_LENGTH), @@ -51,6 +51,7 @@ class VisitorTest extends TestCase $userAgent = $this->generateRandomString(2000), $referer = $this->generateRandomString(50), null, + '', ], [ 'userAgent' => substr($userAgent, 0, Visitor::USER_AGENT_MAX_LENGTH), diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 9e2ca4ec..4c066848 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -34,7 +34,7 @@ class DeleteShortUrlServiceTest extends TestCase public function setUp(): void { $shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( - map(range(0, 10), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())), + map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 038fe457..cf2330b3 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -121,7 +121,7 @@ class ShortUrlResolverTest extends TestCase $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), - fn () => new Visit($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), ))); return $shortUrl; @@ -140,7 +140,7 @@ class ShortUrlResolverTest extends TestCase ])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), - fn () => new Visit($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), ))); return $shortUrl; diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 9b627a17..821db275 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -73,7 +73,7 @@ class VisitsTrackerTest extends TestCase $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); - $list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( $list, @@ -129,7 +129,7 @@ class VisitsTrackerTest extends TestCase $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); $spec = $apiKey === null ? null : $apiKey->spec(); - $list = map(range(0, 1), fn () => new Visit(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index 96caf968..c99d051b 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -57,7 +57,8 @@ class VisitLocatorTest extends TestCase ): void { $unlocatedVisits = map( range(1, 200), - fn (int $i) => new Visit(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), + fn (int $i) => + Visit::forValidShortUrl(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), ); $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); @@ -107,7 +108,7 @@ class VisitLocatorTest extends TestCase bool $isNonLocatableAddress ): void { $unlocatedVisits = [ - new Visit(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), ]; $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 73601748..1e3b3a5c 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -22,19 +22,30 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface { /** @var ShortUrl $abcShortUrl */ $abcShortUrl = $this->getReference('abc123_short_url'); - $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77'))); - $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7'))); - $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); + $manager->persist( + Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77', '')), + ); + $manager->persist(Visit::forValidShortUrl( + $abcShortUrl, + new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7', ''), + )); + $manager->persist(Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1'))); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); + $manager->persist( + Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1', '')), + ); + $manager->persist( + Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), + ); /** @var ShortUrl $ghiShortUrl */ $ghiShortUrl = $this->getReference('ghi789_short_url'); - $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); - $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); + $manager->persist(Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); + $manager->persist( + Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), + ); $manager->flush(); } From 1b4e62b823228ce473b4af87b3a4f1f2f9e385cd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 19:46:51 +0100 Subject: [PATCH 073/115] Separated methods to track visits and list visits --- module/CLI/config/dependencies.config.php | 2 +- .../src/Command/ShortUrl/GetVisitsCommand.php | 13 ++- .../Command/ShortUrl/GetVisitsCommandTest.php | 20 ++-- module/Core/src/Service/VisitsTracker.php | 56 ----------- .../src/Service/VisitsTrackerInterface.php | 19 ---- module/Core/src/Visit/VisitsStatsHelper.php | 59 +++++++++++ .../src/Visit/VisitsStatsHelperInterface.php | 22 +++++ .../Core/test/Service/VisitsTrackerTest.php | 97 ------------------- .../Core/test/Visit/VisitsStatsHelperTest.php | 96 ++++++++++++++++++ module/Rest/config/dependencies.config.php | 4 +- .../src/Action/Visit/ShortUrlVisitsAction.php | 10 +- .../Rest/src/Action/Visit/TagVisitsAction.php | 10 +- .../Action/Visit/ShortUrlVisitsActionTest.php | 12 +-- .../test/Action/Visit/TagVisitsActionTest.php | 10 +- 14 files changed, 220 insertions(+), 210 deletions(-) diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 685dc9fd..7e224c33 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -74,7 +74,7 @@ return [ Service\ShortUrlService::class, ShortUrlDataTransformer::class, ], - Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class], + Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\Visit\LocateVisitsCommand::class => [ diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 0b7de663..7b020356 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -27,11 +27,11 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand { public const NAME = 'short-url:visits'; - private VisitsTrackerInterface $visitsTracker; + private VisitsStatsHelperInterface $visitsHelper; - public function __construct(VisitsTrackerInterface $visitsTracker) + public function __construct(VisitsStatsHelperInterface $visitsHelper) { - $this->visitsTracker = $visitsTracker; + $this->visitsHelper = $visitsHelper; parent::__construct(); } @@ -74,7 +74,10 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand $startDate = $this->getStartDateOption($input, $output); $endDate = $this->getEndDateOption($input, $output); - $paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate))); + $paginator = $this->visitsHelper->visitsForShortUrl( + $identifier, + new VisitsParams(new DateRange($startDate, $endDate)), + ); $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) { $rowData = $visit->jsonSerialize(); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index 4d5e5832..d25d5763 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -31,12 +31,12 @@ class GetVisitsCommandTest extends TestCase use ProphecyTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsTracker; + private ObjectProphecy $visitsHelper; public function setUp(): void { - $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); - $command = new GetVisitsCommand($this->visitsTracker->reveal()); + $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $command = new GetVisitsCommand($this->visitsHelper->reveal()); $app = new Application(); $app->add($command); $this->commandTester = new CommandTester($command); @@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase public function noDateFlagsTriesToListWithoutDateRange(): void { $shortCode = 'abc123'; - $this->visitsTracker->info( + $this->visitsHelper->visitsForShortUrl( new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange(null, null)), ) @@ -62,7 +62,7 @@ class GetVisitsCommandTest extends TestCase $shortCode = 'abc123'; $startDate = '2016-01-01'; $endDate = '2016-02-01'; - $this->visitsTracker->info( + $this->visitsHelper->visitsForShortUrl( new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))), ) @@ -81,8 +81,10 @@ class GetVisitsCommandTest extends TestCase { $shortCode = 'abc123'; $startDate = 'foo'; - $info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange())) - ->willReturn(new Paginator(new ArrayAdapter([]))); + $info = $this->visitsHelper->visitsForShortUrl( + new ShortUrlIdentifier($shortCode), + new VisitsParams(new DateRange()), + )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute([ 'shortCode' => $shortCode, @@ -101,7 +103,7 @@ class GetVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortCode = 'abc123'; - $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( + $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( new Paginator(new ArrayAdapter([ Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')), diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index bda33a01..daf4aa24 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -6,22 +6,10 @@ namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; use Psr\EventDispatcher\EventDispatcherInterface; -use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; -use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; -use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; -use Shlinkio\Shlink\Core\Repository\TagRepository; -use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsTracker implements VisitsTrackerInterface { @@ -48,48 +36,4 @@ class VisitsTracker implements VisitsTrackerInterface $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); } - - /** - * @return Visit[]|Paginator - * @throws ShortUrlNotFoundException - */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator - { - $spec = $apiKey !== null ? $apiKey->spec() : null; - - /** @var ShortUrlRepositoryInterface $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) { - throw ShortUrlNotFoundException::fromNotFound($identifier); - } - - /** @var VisitRepositoryInterface $repo */ - $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); - $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); - - return $paginator; - } - - /** - * @return Visit[]|Paginator - * @throws TagNotFoundException - */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator - { - /** @var TagRepository $tagRepo */ - $tagRepo = $this->em->getRepository(Tag::class); - if (! $tagRepo->tagExists($tag, $apiKey)) { - throw TagNotFoundException::fromTag($tag); - } - - /** @var VisitRepositoryInterface $repo */ - $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); - $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); - - return $paginator; - } } diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 0814d986..c468cf0e 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -4,29 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; -use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsTrackerInterface { public function track(ShortUrl $shortUrl, Visitor $visitor): void; - - /** - * @return Visit[]|Paginator - * @throws ShortUrlNotFoundException - */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; - - /** - * @return Visit[]|Paginator - * @throws TagNotFoundException - */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index ab06079a..7c4efd23 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -5,8 +5,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; +use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; +use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; +use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -30,4 +42,51 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $visitsRepo = $this->em->getRepository(Visit::class); return $visitsRepo->countVisits($apiKey); } + + /** + * @return Visit[]|Paginator + * @throws ShortUrlNotFoundException + */ + public function visitsForShortUrl( + ShortUrlIdentifier $identifier, + VisitsParams $params, + ?ApiKey $apiKey = null + ): Paginator { + $spec = $apiKey !== null ? $apiKey->spec() : null; + + /** @var ShortUrlRepositoryInterface $repo */ + $repo = $this->em->getRepository(ShortUrl::class); + if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) { + throw ShortUrlNotFoundException::fromNotFound($identifier); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); + $paginator->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); + + return $paginator; + } + + /** + * @return Visit[]|Paginator + * @throws TagNotFoundException + */ + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + { + /** @var TagRepository $tagRepo */ + $tagRepo = $this->em->getRepository(Tag::class); + if (! $tagRepo->tagExists($tag, $apiKey)) { + throw TagNotFoundException::fromTag($tag); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); + $paginator->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); + + return $paginator; + } } diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index ca044d4b..a67c8dcd 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -4,10 +4,32 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; +use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsStatsHelperInterface { public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; + + /** + * @return Visit[]|Paginator + * @throws ShortUrlNotFoundException + */ + public function visitsForShortUrl( + ShortUrlIdentifier $identifier, + VisitsParams $params, + ?ApiKey $apiKey = null + ): Paginator; + + /** + * @return Visit[]|Paginator + * @throws TagNotFoundException + */ + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; } diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 821db275..4807dc41 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -5,35 +5,19 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Service; use Doctrine\ORM\EntityManager; -use Laminas\Stdlib\ArrayUtils; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; -use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; -use Shlinkio\Shlink\Core\Repository\TagRepository; -use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; -use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; - -use function Functional\map; -use function range; class VisitsTrackerTest extends TestCase { - use ApiKeyHelpersTrait; use ProphecyTrait; private VisitsTracker $visitsTracker; @@ -60,85 +44,4 @@ class VisitsTrackerTest extends TestCase $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); } - - /** - * @test - * @dataProvider provideAdminApiKeys - */ - public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void - { - $shortCode = '123ABC'; - $spec = $apiKey === null ? null : $apiKey->spec(); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); - - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( - $list, - ); - $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1); - $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - - $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); - - self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $count->shouldHaveBeenCalledOnce(); - } - - /** @test */ - public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void - { - $shortCode = '123ABC'; - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); - - $this->expectException(ShortUrlNotFoundException::class); - $count->shouldBeCalledOnce(); - - $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); - } - - /** @test */ - public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void - { - $tag = 'foo'; - $apiKey = new ApiKey(); - $repo = $this->prophesize(TagRepository::class); - $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); - - $this->expectException(TagNotFoundException::class); - $tagExists->shouldBeCalledOnce(); - $getRepo->shouldBeCalledOnce(); - - $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); - } - - /** - * @test - * @dataProvider provideAdminApiKeys - */ - public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void - { - $tag = 'foo'; - $repo = $this->prophesize(TagRepository::class); - $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); - - $spec = $apiKey === null ? null : $apiKey->spec(); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); - $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); - $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - - $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); - - self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $tagExists->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); - } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index cdc76bd4..20a39316 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -5,19 +5,34 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; +use Laminas\Stdlib\ArrayUtils; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function Functional\map; use function range; class VisitsStatsHelperTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private VisitsStatsHelper $helper; @@ -50,4 +65,85 @@ class VisitsStatsHelperTest extends TestCase { return map(range(0, 50, 5), fn (int $value) => [$value]); } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void + { + $shortCode = '123ABC'; + $spec = $apiKey === null ? null : $apiKey->spec(); + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); + + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( + $list, + ); + $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $count->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void + { + $shortCode = '123ABC'; + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); + + $this->expectException(ShortUrlNotFoundException::class); + $count->shouldBeCalledOnce(); + + $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams()); + } + + /** @test */ + public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void + { + $tag = 'foo'; + $apiKey = new ApiKey(); + $repo = $this->prophesize(TagRepository::class); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $this->expectException(TagNotFoundException::class); + $tagExists->shouldBeCalledOnce(); + $getRepo->shouldBeCalledOnce(); + + $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void + { + $tag = 'foo'; + $repo = $this->prophesize(TagRepository::class); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $spec = $apiKey === null ? null : $apiKey->spec(); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $tagExists->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index cfb97320..5b68ada7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -66,8 +66,8 @@ return [ Service\ShortUrl\ShortUrlResolver::class, ShortUrlDataTransformer::class, ], - Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class], - Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class], + Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 7b7c1055..8175d1c7 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -21,11 +21,11 @@ class ShortUrlVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsTrackerInterface $visitsTracker; + private VisitsStatsHelperInterface $visitsHelper; - public function __construct(VisitsTrackerInterface $visitsTracker) + public function __construct(VisitsStatsHelperInterface $visitsHelper) { - $this->visitsTracker = $visitsTracker; + $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response @@ -33,7 +33,7 @@ class ShortUrlVisitsAction extends AbstractRestAction $identifier = ShortUrlIdentifier::fromApiRequest($request); $params = VisitsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsTracker->info($identifier, $params, $apiKey); + $visits = $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index aec42ebb..8d981c82 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -20,11 +20,11 @@ class TagVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags/{tag}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsTrackerInterface $visitsTracker; + private VisitsStatsHelperInterface $visitsHelper; - public function __construct(VisitsTrackerInterface $visitsTracker) + public function __construct(VisitsStatsHelperInterface $visitsHelper) { - $this->visitsTracker = $visitsTracker; + $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response @@ -32,7 +32,7 @@ class TagVisitsAction extends AbstractRestAction $tag = $request->getAttribute('tag', ''); $params = VisitsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey); + $visits = $this->visitsHelper->visitsForTag($tag, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 9c751214..6b149877 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -25,19 +25,19 @@ class ShortUrlVisitsActionTest extends TestCase use ProphecyTrait; private ShortUrlVisitsAction $action; - private ObjectProphecy $visitsTracker; + private ObjectProphecy $visitsHelper; public function setUp(): void { - $this->visitsTracker = $this->prophesize(VisitsTracker::class); - $this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal()); + $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->action = new ShortUrlVisitsAction($this->visitsHelper->reveal()); } /** @test */ public function providingCorrectShortCodeReturnsVisits(): void { $shortCode = 'abc123'; - $this->visitsTracker->info( + $this->visitsHelper->visitsForShortUrl( new ShortUrlIdentifier($shortCode), Argument::type(VisitsParams::class), Argument::type(ApiKey::class), @@ -52,7 +52,7 @@ class ShortUrlVisitsActionTest extends TestCase public function paramsAreReadFromQuery(): void { $shortCode = 'abc123'; - $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams( + $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams( new DateRange(null, Chronos::parse('2016-01-01 00:00:00')), 3, 10, diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index c9097d07..da046f26 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -12,7 +12,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,12 +21,12 @@ class TagVisitsActionTest extends TestCase use ProphecyTrait; private TagVisitsAction $action; - private ObjectProphecy $visitsTracker; + private ObjectProphecy $visitsHelper; protected function setUp(): void { - $this->visitsTracker = $this->prophesize(VisitsTracker::class); - $this->action = new TagVisitsAction($this->visitsTracker->reveal()); + $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->action = new TagVisitsAction($this->visitsHelper->reveal()); } /** @test */ @@ -34,7 +34,7 @@ class TagVisitsActionTest extends TestCase { $tag = 'foo'; $apiKey = new ApiKey(); - $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( + $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( new Paginator(new ArrayAdapter([])), ); From 36be44e7b5f2ce35f0177d17efe40100f8fd6530 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 19:50:17 +0100 Subject: [PATCH 074/115] Moved VisitsTracker service to Visit namespace --- module/Core/config/dependencies.config.php | 8 ++++---- module/Core/src/Action/AbstractTrackingAction.php | 2 +- module/Core/src/Action/RedirectAction.php | 2 +- module/Core/src/{Service => Visit}/VisitsTracker.php | 2 +- .../src/{Service => Visit}/VisitsTrackerInterface.php | 2 +- module/Core/test/Action/PixelActionTest.php | 2 +- module/Core/test/Action/RedirectActionTest.php | 2 +- module/Core/test/{Service => Visit}/VisitsTrackerTest.php | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) rename module/Core/src/{Service => Visit}/VisitsTracker.php (96%) rename module/Core/src/{Service => Visit}/VisitsTrackerInterface.php (84%) rename module/Core/test/{Service => Visit}/VisitsTrackerTest.php (93%) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index e742ad43..0f182b1b 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -24,7 +24,7 @@ return [ Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, - Service\VisitsTracker::class => ConfigAbstractFactory::class, + Visit\VisitsTracker::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, @@ -75,7 +75,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, Service\ShortUrl\ShortCodeHelper::class, ], - Service\VisitsTracker::class => [ + Visit\VisitsTracker::class => [ 'em', EventDispatcherInterface::class, 'config.url_shortener.anonymize_remote_addr', @@ -104,14 +104,14 @@ return [ Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, - Service\VisitsTracker::class, + Visit\VisitsTracker::class, Options\AppOptions::class, Util\RedirectResponseHelper::class, 'Logger_Shlink', ], Action\PixelAction::class => [ Service\ShortUrl\ShortUrlResolver::class, - Service\VisitsTracker::class, + Visit\VisitsTracker::class, Options\AppOptions::class, 'Logger_Shlink', ], diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 86eb197b..b6a119b2 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -20,7 +20,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; use function array_key_exists; use function array_merge; diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 0fc6232d..d346456b 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -11,8 +11,8 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; +use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface { diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php similarity index 96% rename from module/Core/src/Service/VisitsTracker.php rename to module/Core/src/Visit/VisitsTracker.php index daf4aa24..e4a9f304 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service; +namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM; use Psr\EventDispatcher\EventDispatcherInterface; diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Visit/VisitsTrackerInterface.php similarity index 84% rename from module/Core/src/Service/VisitsTrackerInterface.php rename to module/Core/src/Visit/VisitsTrackerInterface.php index c468cf0e..75f92434 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Visit/VisitsTrackerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service; +namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\Visitor; diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index cae74926..065cc2c4 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Core\Visit\VisitsTracker; class PixelActionTest extends TestCase { diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 411d9a50..f869e2c4 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -19,8 +19,8 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; +use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; use function array_key_exists; diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php similarity index 93% rename from module/Core/test/Service/VisitsTrackerTest.php rename to module/Core/test/Visit/VisitsTrackerTest.php index 4807dc41..a7ca98c3 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Service; +namespace ShlinkioTest\Shlink\Core\Visit; use Doctrine\ORM\EntityManager; use PHPUnit\Framework\TestCase; @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Core\Visit\VisitsTracker; class VisitsTrackerTest extends TestCase { From 15061d3e0d66b35654383def411d5e9e370086ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 21:38:19 +0100 Subject: [PATCH 075/115] Created new middlewares to track not found visits --- .../autoload/middleware-pipeline.global.php | 2 + module/Core/config/dependencies.config.php | 5 +- .../src/ErrorHandler/Model/NotFoundType.php | 57 +++++++++++++++++++ .../ErrorHandler/NotFoundRedirectHandler.php | 35 +++--------- .../NotFoundTrackerMiddleware.php | 44 ++++++++++++++ .../NotFoundTypeResolverMiddleware.php | 27 +++++++++ .../Core/src/Repository/VisitRepository.php | 7 ++- module/Core/src/Visit/VisitsTracker.php | 23 +++++++- .../Core/src/Visit/VisitsTrackerInterface.php | 6 ++ .../NotFoundRedirectHandlerTest.php | 21 ++++--- 10 files changed, 187 insertions(+), 40 deletions(-) create mode 100644 module/Core/src/ErrorHandler/Model/NotFoundType.php create mode 100644 module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php create mode 100644 module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 9f8cc729..b83ee2e7 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -64,6 +64,8 @@ return [ ], 'not-found' => [ 'middleware' => [ + Core\ErrorHandler\NotFoundTypeResolverMiddleware::class, + Core\ErrorHandler\NotFoundTrackerMiddleware::class, Core\ErrorHandler\NotFoundRedirectHandler::class, Core\ErrorHandler\NotFoundTemplateHandler::class, ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 0f182b1b..50669f66 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -15,6 +15,8 @@ return [ 'dependencies' => [ 'factories' => [ + ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class, + ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class, ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class, ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class, @@ -58,10 +60,11 @@ return [ ], ConfigAbstractFactory::class => [ + ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], + ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ NotFoundRedirectOptions::class, Util\RedirectResponseHelper::class, - 'config.router.base_path', ], Options\AppOptions::class => ['config.app_options'], diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php new file mode 100644 index 00000000..7585a3ca --- /dev/null +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -0,0 +1,57 @@ +type = $type; + } + + public static function fromRequest(ServerRequestInterface $request, string $basePath): self + { + $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath; + if ($isBaseUrl) { + return new self(Visit::TYPE_BASE_URL); + } + + /** @var RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); + if ($routeResult->isFailure()) { + return new self(Visit::TYPE_REGULAR_404); + } + + if ($routeResult->getMatchedRouteName() === RedirectAction::class) { + return new self(Visit::TYPE_INVALID_SHORT_URL); + } + + return new self(self::class); + } + + public function isBaseUrl(): bool + { + return $this->type === Visit::TYPE_BASE_URL; + } + + public function isRegularNotFound(): bool + { + return $this->type === Visit::TYPE_REGULAR_404; + } + + public function isInvalidShortUrl(): bool + { + return $this->type === Visit::TYPE_INVALID_SHORT_URL; + } +} diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index a49db5bb..1f3b4fed 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -4,67 +4,48 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ErrorHandler; -use Mezzio\Router\RouteResult; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UriInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; -use function rtrim; - class NotFoundRedirectHandler implements MiddlewareInterface { private Options\NotFoundRedirectOptions $redirectOptions; private RedirectResponseHelperInterface $redirectResponseHelper; - private string $shlinkBasePath; public function __construct( Options\NotFoundRedirectOptions $redirectOptions, - RedirectResponseHelperInterface $redirectResponseHelper, - string $shlinkBasePath + RedirectResponseHelperInterface $redirectResponseHelper ) { $this->redirectOptions = $redirectOptions; - $this->shlinkBasePath = $shlinkBasePath; $this->redirectResponseHelper = $redirectResponseHelper; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - /** @var RouteResult $routeResult */ - $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); - $redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri()); + /** @var NotFoundType $notFoundType */ + $notFoundType = $request->getAttribute(NotFoundType::class); - return $redirectResponse ?? $handler->handle($request); - } - - private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface - { - $isBaseUrl = rtrim($uri->getPath(), '/') === $this->shlinkBasePath; - - if ($isBaseUrl && $this->redirectOptions->hasBaseUrlRedirect()) { + if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) { return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect()); } - if (!$isBaseUrl && $routeResult->isFailure() && $this->redirectOptions->hasRegular404Redirect()) { + if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) { return $this->redirectResponseHelper->buildRedirectResponse( $this->redirectOptions->getRegular404Redirect(), ); } - if ( - $routeResult->isSuccess() && - $routeResult->getMatchedRouteName() === RedirectAction::class && - $this->redirectOptions->hasInvalidShortUrlRedirect() - ) { + if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) { return $this->redirectResponseHelper->buildRedirectResponse( $this->redirectOptions->getInvalidShortUrlRedirect(), ); } - return null; + return $handler->handle($request); } } diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php new file mode 100644 index 00000000..f792dd07 --- /dev/null +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -0,0 +1,44 @@ +visitsTracker = $visitsTracker; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + /** @var NotFoundType $notFoundType */ + $notFoundType = $request->getAttribute(NotFoundType::class); + $visitor = Visitor::fromRequest($request); + + if ($notFoundType->isBaseUrl()) { + $this->visitsTracker->trackBaseUrlVisit($visitor); + } + + if ($notFoundType->isRegularNotFound()) { + $this->visitsTracker->trackRegularNotFoundVisit($visitor); + } + + if ($notFoundType->isInvalidShortUrl()) { + $this->visitsTracker->trackInvalidShortUrlVisit($visitor); + } + + return $handler->handle($request); + } +} diff --git a/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php new file mode 100644 index 00000000..6f13db73 --- /dev/null +++ b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php @@ -0,0 +1,27 @@ +shlinkBasePath = $shlinkBasePath; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $notFoundType = NotFoundType::fromRequest($request, $this->shlinkBasePath); + return $handler->handle($request->withAttribute(NotFoundType::class, $notFoundType)); + } +} diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index a1df73a5..61bc50bb 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -211,8 +211,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function countVisits(?ApiKey $apiKey = null): int { - return (int) $this->matchSingleScalarResult( - Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')), - ); + return (int) $this->matchSingleScalarResult(Spec::countOf(Spec::andX( + Spec::isNotNull('shortUrl'), + new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'), + ))); } } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index e4a9f304..af170cfc 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -29,11 +29,30 @@ class VisitsTracker implements VisitsTrackerInterface public function track(ShortUrl $shortUrl, Visitor $visitor): void { - $visit = Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr); + $visit = $this->trackVisit(Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr)); + $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); + } + public function trackInvalidShortUrlVisit(Visitor $visitor): void + { + $this->trackVisit(Visit::forInvalidShortUrl($visitor)); + } + + public function trackBaseUrlVisit(Visitor $visitor): void + { + $this->trackVisit(Visit::forBasePath($visitor)); + } + + public function trackRegularNotFoundVisit(Visitor $visitor): void + { + $this->trackVisit(Visit::forRegularNotFound($visitor)); + } + + private function trackVisit(Visit $visit): Visit + { $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); + return $visit; } } diff --git a/module/Core/src/Visit/VisitsTrackerInterface.php b/module/Core/src/Visit/VisitsTrackerInterface.php index 75f92434..ae70d550 100644 --- a/module/Core/src/Visit/VisitsTrackerInterface.php +++ b/module/Core/src/Visit/VisitsTrackerInterface.php @@ -10,4 +10,10 @@ use Shlinkio\Shlink\Core\Model\Visitor; interface VisitsTrackerInterface { public function track(ShortUrl $shortUrl, Visitor $visitor): void; + + public function trackInvalidShortUrlVisit(Visitor $visitor): void; + + public function trackBaseUrlVisit(Visitor $visitor): void; + + public function trackRegularNotFoundVisit(Visitor $visitor): void; } diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 83810d22..9df49879 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -17,6 +17,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; @@ -33,7 +34,7 @@ class NotFoundRedirectHandlerTest extends TestCase { $this->redirectOptions = new NotFoundRedirectOptions(); $this->helper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal(), ''); + $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal()); } /** @@ -64,19 +65,19 @@ class NotFoundRedirectHandlerTest extends TestCase public function provideRedirects(): iterable { yield 'base URL with trailing slash' => [ - ServerRequestFactory::fromGlobals()->withUri(new Uri('/')), + $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), 'baseUrl', ]; yield 'base URL without trailing slash' => [ - ServerRequestFactory::fromGlobals()->withUri(new Uri('')), + $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), 'baseUrl', ]; yield 'regular 404' => [ - ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar')), + $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), 'regular404', ]; yield 'invalid short URL' => [ - ServerRequestFactory::fromGlobals() + $this->withNotFoundType(ServerRequestFactory::fromGlobals() ->withAttribute( RouteResult::class, RouteResult::fromRoute( @@ -88,7 +89,7 @@ class NotFoundRedirectHandlerTest extends TestCase ), ), ) - ->withUri(new Uri('/abc123')), + ->withUri(new Uri('/abc123'))), 'invalidShortUrl', ]; } @@ -96,7 +97,7 @@ class NotFoundRedirectHandlerTest extends TestCase /** @test */ public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void { - $req = ServerRequestFactory::fromGlobals(); + $req = $this->withNotFoundType(ServerRequestFactory::fromGlobals()); $resp = new Response(); $buildResp = $this->helper->buildRedirectResponse(Argument::cetera()); @@ -110,4 +111,10 @@ class NotFoundRedirectHandlerTest extends TestCase $buildResp->shouldNotHaveBeenCalled(); $handle->shouldHaveBeenCalledOnce(); } + + private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface + { + $type = NotFoundType::fromRequest($req, ''); + return $req->withAttribute(NotFoundType::class, $type); + } } From 55e7f7ccb069fd32319bf605580ffe9cb7f54fb3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 22:00:07 +0100 Subject: [PATCH 076/115] Improved VisitRepository tests --- .../Core/src/Repository/VisitRepository.php | 14 +++++---- .../Repository/VisitRepositoryInterface.php | 2 ++ .../src/Visit/Spec/CountOfOrphanVisits.php | 17 +++++++++++ .../src/Visit/Spec/CountOfShortUrlVisits.php | 30 +++++++++++++++++++ .../Repository/VisitRepositoryTest.php | 8 ++++- 5 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 module/Core/src/Visit/Spec/CountOfOrphanVisits.php create mode 100644 module/Core/src/Visit/Spec/CountOfShortUrlVisits.php diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 61bc50bb..082b17b8 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\EntitySpecificationRepository; -use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; -use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits; +use Shlinkio\Shlink\Core\Visit\Spec\CountOfShortUrlVisits; use Shlinkio\Shlink\Rest\Entity\ApiKey; use const PHP_INT_MAX; @@ -211,9 +211,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function countVisits(?ApiKey $apiKey = null): int { - return (int) $this->matchSingleScalarResult(Spec::countOf(Spec::andX( - Spec::isNotNull('shortUrl'), - new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'), - ))); + return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); + } + + public function countOrphanVisits(): int + { + return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits()); } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 526645df..0d637f75 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -63,4 +63,6 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; public function countVisits(?ApiKey $apiKey = null): int; + + public function countOrphanVisits(): int; } diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php new file mode 100644 index 00000000..7e15f330 --- /dev/null +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -0,0 +1,17 @@ +apiKey = $apiKey; + } + + protected function getSpec(): Specification + { + return Spec::countOf(Spec::andX( + Spec::isNotNull('shortUrl'), + new WithApiKeySpecsEnsuringJoin($this->apiKey, 'shortUrl'), + )); + } +} diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 5681ee26..00b558d4 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -168,7 +168,7 @@ class VisitRepositoryTest extends DatabaseTestCase } /** @test */ - public function countReturnsExpectedResultBasedOnApiKey(): void + public function countVisitsReturnsExpectedResultBasedOnApiKey(): void { $domain = new Domain('foo.com'); $this->getEntityManager()->persist($domain); @@ -200,12 +200,18 @@ class VisitRepositoryTest extends DatabaseTestCase $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain)); $this->getEntityManager()->persist($domainApiKey); + // Visits not linked to any short URL + $this->getEntityManager()->persist(Visit::forBasePath(Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::emptyInstance())); + $this->getEntityManager()->flush(); self::assertEquals(4 + 5 + 7, $this->repo->countVisits()); self::assertEquals(4, $this->repo->countVisits($apiKey1)); self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2)); self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey)); + self::assertEquals(3, $this->repo->countOrphanVisits()); } private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array From 0e165bc7e06577df1be648b7d4df904188600186 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 22:06:03 +0100 Subject: [PATCH 077/115] Created NotFoundTypeResolverMiddlewareTest --- .../NotFoundTypeResolverMiddlewareTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php diff --git a/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php new file mode 100644 index 00000000..c5d9be79 --- /dev/null +++ b/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php @@ -0,0 +1,47 @@ +middleware = new NotFoundTypeResolverMiddleware(''); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + } + + /** @test */ + public function notFoundTypeIsAddedToRequest(): void + { + $request = ServerRequestFactory::fromGlobals(); + $handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) { + Assert::assertArrayHasKey(NotFoundType::class, $req->getAttributes()); + + return true; + }))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + self::assertArrayNotHasKey(NotFoundType::class, $request->getAttributes()); + $handle->shouldHaveBeenCalledOnce(); + } +} From d2e0413a48490d90da2c49e6a605816528ac8a5b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 22:16:15 +0100 Subject: [PATCH 078/115] Added NotFoundTrackerMiddlewareTest --- .../src/ErrorHandler/Model/NotFoundType.php | 2 +- .../NotFoundTrackerMiddleware.php | 8 +- .../NotFoundTrackerMiddlewareTest.php | 95 +++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 7585a3ca..57176e84 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; use function rtrim; -final class NotFoundType +class NotFoundType { private string $type; diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index f792dd07..b81e55de 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -29,13 +29,9 @@ class NotFoundTrackerMiddleware implements MiddlewareInterface if ($notFoundType->isBaseUrl()) { $this->visitsTracker->trackBaseUrlVisit($visitor); - } - - if ($notFoundType->isRegularNotFound()) { + } elseif ($notFoundType->isRegularNotFound()) { $this->visitsTracker->trackRegularNotFoundVisit($visitor); - } - - if ($notFoundType->isInvalidShortUrl()) { + } elseif ($notFoundType->isInvalidShortUrl()) { $this->visitsTracker->trackInvalidShortUrlVisit($visitor); } diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php new file mode 100644 index 00000000..560a2468 --- /dev/null +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -0,0 +1,95 @@ +notFoundType = $this->prophesize(NotFoundType::class); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler->handle(Argument::cetera())->willReturn(new Response()); + + $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); + $this->middleware = new NotFoundTrackerMiddleware($this->visitsTracker->reveal()); + + $this->request = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->notFoundType->reveal(), + ); + } + + /** @test */ + public function baseUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->middleware->process($this->request, $this->handler->reveal()); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldNotHaveBeenCalled(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function regularNotFoundErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->middleware->process($this->request, $this->handler->reveal()); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function invalidShortUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); + + $this->middleware->process($this->request, $this->handler->reveal()); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + } +} From f7215fc2c5e8aca565f353fbe63521f70ea72f17 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 22:20:18 +0100 Subject: [PATCH 079/115] Documented ADR decision outcome --- ...ck-visits-to-base-url-invalid-short-url-and-regular-404.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md index 032820ef..983410d1 100644 --- a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md +++ b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md @@ -18,7 +18,9 @@ The intention is to change that, and allow users to track the cases mentioned ab ## Decision outcome -*TBD* +The decision is to use the existing table, as making the short URL nullable can be handled seamlessly by using named constructors, and it has a lot of benefits on regards of reusing existing components. + +Also, the domain name this kind of visits will receive is "Orphan Visits", as they are detached from any existing short URL. ## Pros and Cons of the Options From 5278d7668cb4bb7b0d999ed61a93f958f8289a2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 8 Feb 2021 22:44:58 +0100 Subject: [PATCH 080/115] Added orphan visits count to visits stats endpoint --- docs/swagger/definitions/VisitStats.json | 8 ++++++-- docs/swagger/paths/v2_visits.json | 3 ++- module/Core/src/Visit/Model/VisitsStats.php | 5 ++++- module/Core/src/Visit/VisitsStatsHelper.php | 8 ++------ module/Core/test/Visit/VisitsStatsHelperTest.php | 6 ++++-- module/Rest/test-api/Action/GlobalVisitsTest.php | 2 ++ module/Rest/test-api/Fixtures/VisitsFixture.php | 8 ++++++++ module/Rest/test/Action/Visit/GlobalVisitsActionTest.php | 2 +- 8 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json index 5f439c9b..2a97f597 100644 --- a/docs/swagger/definitions/VisitStats.json +++ b/docs/swagger/definitions/VisitStats.json @@ -1,10 +1,14 @@ { "type": "object", - "required": ["visitsCount"], + "required": ["visitsCount", "orphanVisitsCount"], "properties": { "visitsCount": { "type": "number", - "description": "The total amount of visits received." + "description": "The total amount of visits received on any short URL." + }, + "orphanVisitsCount": { + "type": "number", + "description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)." } } } diff --git a/docs/swagger/paths/v2_visits.json b/docs/swagger/paths/v2_visits.json index 089223b3..3c712b1f 100644 --- a/docs/swagger/paths/v2_visits.json +++ b/docs/swagger/paths/v2_visits.json @@ -34,7 +34,8 @@ "examples": { "application/json": { "visits": { - "visitsCount": 1569874 + "visitsCount": 1569874, + "orphanVisitsCount": 71345 } } } diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index ac5083c7..982f03c4 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -9,16 +9,19 @@ use JsonSerializable; final class VisitsStats implements JsonSerializable { private int $visitsCount; + private int $orphanVisitsCount; - public function __construct(int $visitsCount) + public function __construct(int $visitsCount, int $orphanVisitsCount) { $this->visitsCount = $visitsCount; + $this->orphanVisitsCount = $orphanVisitsCount; } public function jsonSerialize(): array { return [ 'visitsCount' => $this->visitsCount, + 'orphanVisitsCount' => $this->orphanVisitsCount, ]; } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 7c4efd23..0cb58897 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -32,15 +32,11 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface } public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats - { - return new VisitsStats($this->getVisitsCount($apiKey)); - } - - private function getVisitsCount(?ApiKey $apiKey): int { /** @var VisitRepository $visitsRepo */ $visitsRepo = $this->em->getRepository(Visit::class); - return $visitsRepo->countVisits($apiKey); + + return new VisitsStats($visitsRepo->countVisits($apiKey), $visitsRepo->countOrphanVisits()); } /** diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 20a39316..6fefd1d0 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -51,13 +51,15 @@ class VisitsStatsHelperTest extends TestCase public function returnsExpectedVisitsStats(int $expectedCount): void { $repo = $this->prophesize(VisitRepository::class); - $count = $repo->countVisits(null)->willReturn($expectedCount); + $count = $repo->countVisits(null)->willReturn($expectedCount * 3); + $countOrphan = $repo->countOrphanVisits()->willReturn($expectedCount); $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); $stats = $this->helper->getVisitsStats(); - self::assertEquals(new VisitsStats($expectedCount), $stats); + self::assertEquals(new VisitsStats($expectedCount * 3, $expectedCount), $stats); $count->shouldHaveBeenCalledOnce(); + $countOrphan->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } diff --git a/module/Rest/test-api/Action/GlobalVisitsTest.php b/module/Rest/test-api/Action/GlobalVisitsTest.php index 99e05918..1b71f976 100644 --- a/module/Rest/test-api/Action/GlobalVisitsTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsTest.php @@ -19,7 +19,9 @@ class GlobalVisitsTest extends ApiTestCase self::assertArrayHasKey('visits', $payload); self::assertArrayHasKey('visitsCount', $payload['visits']); + self::assertArrayHasKey('orphanVisitsCount', $payload['visits']); self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); + self::assertEquals(3, $payload['visits']['orphanVisitsCount']); } public function provideApiKeys(): iterable diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 1e3b3a5c..9fb53ac1 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -47,6 +47,14 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), ); + $manager->persist(Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', ''))); + $manager->persist( + Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://doma.in/foo/bar', '1.2.3.4', '')), + ); + $manager->persist( + Visit::forInvalidShortUrl(new Visitor('shlink-tests-agent', 'https://doma.in/foo', '1.2.3.4', '')), + ); + $manager->flush(); } } diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index 6e3ab1e4..d53cb20d 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -31,7 +31,7 @@ class GlobalVisitsActionTest extends TestCase public function statsAreReturnedFromHelper(): void { $apiKey = new ApiKey(); - $stats = new VisitsStats(5); + $stats = new VisitsStats(5, 3); $getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats); /** @var JsonResponse $resp */ From b01487ac91f67bd2bca3eb921a2ec3d98a53727f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 17:28:06 +0100 Subject: [PATCH 081/115] Ensured IP address is resolved when tracking orphan visits --- .../autoload/middleware-pipeline.global.php | 3 ++ .../ErrorHandler/NotFoundTemplateHandler.php | 8 ++--- .../NotFoundTemplateHandlerTest.php | 34 ++++++++++++++----- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index b83ee2e7..1deff0fa 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -9,6 +9,7 @@ use Mezzio\Helper; use Mezzio\ProblemDetails; use Mezzio\Router; use PhpMiddleware\RequestId\RequestIdMiddleware; +use RKA\Middleware\IpAddress; return [ @@ -64,6 +65,8 @@ return [ ], 'not-found' => [ 'middleware' => [ + // This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking + IpAddress::class, Core\ErrorHandler\NotFoundTypeResolverMiddleware::class, Core\ErrorHandler\NotFoundTrackerMiddleware::class, Core\ErrorHandler\NotFoundRedirectHandler::class, diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index 62b78973..61d67403 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Core\ErrorHandler; use Closure; use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response; -use Mezzio\Router\RouteResult; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use function file_get_contents; use function sprintf; @@ -29,11 +29,11 @@ class NotFoundTemplateHandler implements RequestHandlerInterface public function handle(ServerRequestInterface $request): ResponseInterface { - /** @var RouteResult $routeResult */ - $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null); + /** @var NotFoundType $notFoundType */ + $notFoundType = $request->getAttribute(NotFoundType::class); $status = StatusCodeInterface::STATUS_NOT_FOUND; - $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; + $template = $notFoundType->isInvalidShortUrl() ? self::INVALID_SHORT_CODE_TEMPLATE : self::NOT_FOUND_TEMPLATE; $templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template)); return new Response\HtmlResponse($templateContent, $status); } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index 6b9f9989..b5c80de4 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -7,27 +7,29 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Closure; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; +use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; +use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler; class NotFoundTemplateHandlerTest extends TestCase { private NotFoundTemplateHandler $handler; - private Closure $readFile; private bool $readFileCalled; public function setUp(): void { $this->readFileCalled = false; - $this->readFile = function (string $fileName): string { + $readFile = function (string $fileName): string { $this->readFileCalled = true; return $fileName; }; - $this->handler = new NotFoundTemplateHandler($this->readFile); + $this->handler = new NotFoundTemplateHandler($readFile); } /** @@ -45,15 +47,29 @@ class NotFoundTemplateHandlerTest extends TestCase public function provideTemplates(): iterable { - $request = ServerRequestFactory::fromGlobals(); + $request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo')); - yield [$request, NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; - yield [ - $request->withAttribute( + yield 'base url' => [$this->withNotFoundType($request, '/foo'), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; + yield 'regular not found' => [$this->withNotFoundType($request), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; + yield 'invalid short code' => [ + $this->withNotFoundType($request->withAttribute( RouteResult::class, - RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal())), - ), + RouteResult::fromRoute( + new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + RedirectAction::class, + ), + ), + )), NotFoundTemplateHandler::INVALID_SHORT_CODE_TEMPLATE, ]; } + + private function withNotFoundType(ServerRequestInterface $req, string $baseUrl = ''): ServerRequestInterface + { + $type = NotFoundType::fromRequest($req, $baseUrl); + return $req->withAttribute(NotFoundType::class, $type); + } } From ab9042db2464031b8d0310bef5ac82e5a8928098 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 20:25:28 +0100 Subject: [PATCH 082/115] Ensured orphan visits are located ASAP when using swoole --- .../Core/config/event_dispatcher.config.php | 10 ++-- module/Core/src/Entity/Visit.php | 5 ++ .../{ShortUrlVisited.php => UrlVisited.php} | 2 +- ...ocateShortUrlVisit.php => LocateVisit.php} | 10 ++-- module/Core/src/Visit/VisitsTracker.php | 15 +++--- .../NotFoundTemplateHandlerTest.php | 1 - ...rtUrlVisitTest.php => LocateVisitTest.php} | 49 ++++++++++++------- module/Core/test/Visit/VisitsTrackerTest.php | 4 +- 8 files changed, 57 insertions(+), 39 deletions(-) rename module/Core/src/EventDispatcher/Event/{ShortUrlVisited.php => UrlVisited.php} (88%) rename module/Core/src/EventDispatcher/{LocateShortUrlVisit.php => LocateVisit.php} (93%) rename module/Core/test/EventDispatcher/{LocateShortUrlVisitTest.php => LocateVisitTest.php} (85%) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 66a23637..5c2c88e0 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -20,28 +20,28 @@ return [ ], ], 'async' => [ - EventDispatcher\Event\ShortUrlVisited::class => [ - EventDispatcher\LocateShortUrlVisit::class, + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], ], ], 'dependencies' => [ 'factories' => [ - EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class, + EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, ], 'delegators' => [ - EventDispatcher\LocateShortUrlVisit::class => [ + EventDispatcher\LocateVisit::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], ], ], ConfigAbstractFactory::class => [ - EventDispatcher\LocateShortUrlVisit::class => [ + EventDispatcher\LocateVisit::class => [ IpLocationResolverInterface::class, 'em', 'Logger_Shlink', diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index d61e8af6..3bcee8e1 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -109,6 +109,11 @@ class Visit extends AbstractEntity implements JsonSerializable return $this; } + public function isOrphan(): bool + { + return $this->shortUrl === null; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php similarity index 88% rename from module/Core/src/EventDispatcher/Event/ShortUrlVisited.php rename to module/Core/src/EventDispatcher/Event/UrlVisited.php index f177721f..87b9e4cb 100644 --- a/module/Core/src/EventDispatcher/Event/ShortUrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher\Event; -final class ShortUrlVisited extends AbstractVisitEvent +final class UrlVisited extends AbstractVisitEvent { private ?string $originalIpAddress; diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php similarity index 93% rename from module/Core/src/EventDispatcher/LocateShortUrlVisit.php rename to module/Core/src/EventDispatcher/LocateVisit.php index 8b193578..5e3baf74 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; -use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -19,7 +19,7 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use function sprintf; -class LocateShortUrlVisit +class LocateVisit { private IpLocationResolverInterface $ipLocationResolver; private EntityManagerInterface $em; @@ -41,7 +41,7 @@ class LocateShortUrlVisit $this->eventDispatcher = $eventDispatcher; } - public function __invoke(ShortUrlVisited $shortUrlVisited): void + public function __invoke(UrlVisited $shortUrlVisited): void { $visitId = $shortUrlVisited->visitId(); @@ -58,7 +58,9 @@ class LocateShortUrlVisit $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); } - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + if (! $visit->isOrphan()) { + $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + } } private function downloadOrUpdateGeoLiteDb(string $visitId): bool diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index af170cfc..48157e3b 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -8,7 +8,7 @@ use Doctrine\ORM; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; class VisitsTracker implements VisitsTrackerInterface @@ -29,30 +29,29 @@ class VisitsTracker implements VisitsTrackerInterface public function track(ShortUrl $shortUrl, Visitor $visitor): void { - $visit = $this->trackVisit(Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr)); - $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); + $this->trackVisit(Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr), $visitor); } public function trackInvalidShortUrlVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forInvalidShortUrl($visitor)); + $this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->anonymizeRemoteAddr), $visitor); } public function trackBaseUrlVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forBasePath($visitor)); + $this->trackVisit(Visit::forBasePath($visitor, $this->anonymizeRemoteAddr), $visitor); } public function trackRegularNotFoundVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forRegularNotFound($visitor)); + $this->trackVisit(Visit::forRegularNotFound($visitor, $this->anonymizeRemoteAddr), $visitor); } - private function trackVisit(Visit $visit): Visit + private function trackVisit(Visit $visit, Visitor $visitor): void { $this->em->persist($visit); $this->em->flush(); - return $visit; + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); } } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index b5c80de4..dcf42b54 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; -use Closure; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php similarity index 85% rename from module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php rename to module/Core/test/EventDispatcher/LocateVisitTest.php index 03eef5f6..af21e3dc 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -17,19 +17,19 @@ use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; -use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; -use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit; +use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -class LocateShortUrlVisitTest extends TestCase +class LocateVisitTest extends TestCase { use ProphecyTrait; - private LocateShortUrlVisit $locateVisit; + private LocateVisit $locateVisit; private ObjectProphecy $ipLocationResolver; private ObjectProphecy $em; private ObjectProphecy $logger; @@ -44,7 +44,7 @@ class LocateShortUrlVisitTest extends TestCase $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->locateVisit = new LocateShortUrlVisit( + $this->locateVisit = new LocateVisit( $this->ipLocationResolver->reveal(), $this->em->reveal(), $this->logger->reveal(), @@ -56,7 +56,7 @@ class LocateShortUrlVisitTest extends TestCase /** @test */ public function invalidVisitLogsWarning(): void { - $event = new ShortUrlVisited('123'); + $event = new UrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn(null); $logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ 'visitId' => 123, @@ -76,7 +76,7 @@ class LocateShortUrlVisitTest extends TestCase /** @test */ public function invalidAddressLogsWarning(): void { - $event = new ShortUrlVisited('123'); + $event = new UrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn( Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), ); @@ -105,7 +105,7 @@ class LocateShortUrlVisitTest extends TestCase */ public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void { - $event = new ShortUrlVisited('123'); + $event = new UrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { }); @@ -136,12 +136,14 @@ class LocateShortUrlVisitTest extends TestCase * @test * @dataProvider provideIpAddresses */ - public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void - { - $ipAddr = $originalIpAddress ?? $anonymizedIpAddress; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); + public function locatableVisitsResolveToLocation( + Visit $visit, + ?string $originalIpAddress, + int $expectedDispatchCalls + ): void { + $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new ShortUrlVisited('123', $originalIpAddress); + $event = new UrlVisited('123', $originalIpAddress); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { @@ -157,13 +159,24 @@ class LocateShortUrlVisitTest extends TestCase $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledOnce(); + $dispatch->shouldHaveBeenCalledTimes($expectedDispatchCalls); } public function provideIpAddresses(): iterable { - yield 'no original IP address' => ['1.2.3.0', null]; - yield 'original IP address' => ['1.2.3.0', '1.2.3.4']; + yield 'no original IP address' => [ + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + null, + 1, + ]; + yield 'original IP address' => [ + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + '1.2.3.4', + 1, + ]; + yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; + yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; + yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; } /** @test */ @@ -173,7 +186,7 @@ class LocateShortUrlVisitTest extends TestCase $ipAddr = '1.2.3.0'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new ShortUrlVisited('123'); + $event = new UrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { @@ -204,7 +217,7 @@ class LocateShortUrlVisitTest extends TestCase $ipAddr = '1.2.3.0'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new ShortUrlVisited('123'); + $event = new UrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index a7ca98c3..2d8585d0 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -12,7 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsTracker; @@ -42,6 +42,6 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker->track(ShortUrl::withLongUrl($shortCode), Visitor::emptyInstance()); - $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); + $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); } } From 1fbcb44136bf6035fc0e169072cc3b18e24e8f19 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 20:34:12 +0100 Subject: [PATCH 083/115] Enhanced VisitsTrackerTest --- module/Core/test/Visit/VisitsTrackerTest.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 2d8585d0..fd6e341f 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -32,16 +32,25 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); } - /** @test */ - public function trackPersistsVisit(): void + /** + * @test + * @dataProvider provideTrackingMethodNames + */ + public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void { - $shortCode = '123ABC'; - $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce(); $this->em->flush()->shouldBeCalledOnce(); - $this->visitsTracker->track(ShortUrl::withLongUrl($shortCode), Visitor::emptyInstance()); + $this->visitsTracker->{$method}(...$args); $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); } + + public function provideTrackingMethodNames(): iterable + { + yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; + yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; + yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; + yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; + } } From 85dd023c0e37c90ca32f57d02258eb3e3f8a3081 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 21:22:36 +0100 Subject: [PATCH 084/115] Created methods to get orphan visits lists --- composer.json | 2 +- module/Core/src/Entity/Visit.php | 19 ++-- .../Core/src/Repository/VisitRepository.php | 33 ++++--- .../Repository/VisitRepositoryInterface.php | 9 +- module/Core/src/Spec/InDateRange.php | 38 ++++++++ .../src/Visit/Spec/CountOfOrphanVisits.php | 15 +++- .../Repository/VisitRepositoryTest.php | 87 ++++++++++++++++++- 7 files changed, 173 insertions(+), 30 deletions(-) create mode 100644 module/Core/src/Spec/InDateRange.php diff --git a/composer.json b/composer.json index 29068f06..ec9a028d 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-main#b889f5d as 3.5", + "shlinkio/shlink-common": "dev-main#62d4b84 as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.2", diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 3bcee8e1..efcf5d8f 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -28,15 +28,10 @@ class Visit extends AbstractEntity implements JsonSerializable private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; - public function __construct( - ?ShortUrl $shortUrl, - Visitor $visitor, - bool $anonymize = true, - ?Chronos $date = null, - string $type = self::TYPE_VALID_SHORT_URL - ) { + private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true) + { $this->shortUrl = $shortUrl; - $this->date = $date ?? Chronos::now(); + $this->date = Chronos::now(); $this->userAgent = $visitor->getUserAgent(); $this->referer = $visitor->getReferer(); $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); @@ -60,22 +55,22 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self { - return new self($shortUrl, $visitor, $anonymize); + return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize); } public static function forBasePath(Visitor $visitor, bool $anonymize = true): self { - return new self(null, $visitor, $anonymize, null, self::TYPE_BASE_URL); + return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize); } public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self { - return new self(null, $visitor, $anonymize, null, self::TYPE_INVALID_SHORT_URL); + return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize); } public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self { - return new self(null, $visitor, $anonymize, null, self::TYPE_REGULAR_404); + return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize); } public function getRemoteAddr(): ?string diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 082b17b8..b869093e 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -168,6 +168,29 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $qb; } + public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + // Since they are not strictly provided by the caller, it's reasonably safe + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Visit::class, 'v') + ->where($qb->expr()->isNull('v.shortUrl')); + + $this->applyDatesInline($qb, $dateRange); + + return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); + } + + public function countOrphanVisits(?DateRange $dateRange = null): int + { + return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($dateRange)); + } + + public function countVisits(?ApiKey $apiKey = null): int + { + return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); + } + private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void { if ($dateRange !== null && $dateRange->getStartDate() !== null) { @@ -208,14 +231,4 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $query->getResult(); } - - public function countVisits(?ApiKey $apiKey = null): int - { - return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); - } - - public function countOrphanVisits(): int - { - return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits()); - } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 0d637f75..3ecf0bca 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -62,7 +62,12 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; - public function countVisits(?ApiKey $apiKey = null): int; + /** + * @return Visit[] + */ + public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array; - public function countOrphanVisits(): int; + public function countOrphanVisits(?DateRange $dateRange = null): int; + + public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php new file mode 100644 index 00000000..44944aed --- /dev/null +++ b/module/Core/src/Spec/InDateRange.php @@ -0,0 +1,38 @@ +dateRange = $dateRange; + $this->field = $field; + } + + protected function getSpec(): Specification + { + $criteria = []; + + if ($this->dateRange !== null && $this->dateRange->getStartDate() !== null) { + $criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString()); + } + + if ($this->dateRange !== null && $this->dateRange->getEndDate() !== null) { + $criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString()); + } + + return Spec::andX(...$criteria); + } +} diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index 7e15f330..fb8ee3bd 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -7,11 +7,24 @@ namespace Shlinkio\Shlink\Core\Visit\Spec; use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Spec\InDateRange; class CountOfOrphanVisits extends BaseSpecification { + private ?DateRange $dateRange; + + public function __construct(?DateRange $dateRange) + { + parent::__construct(); + $this->dateRange = $dateRange; + } + protected function getSpec(): Specification { - return Spec::countOf(Spec::isNull('shortUrl')); + return Spec::countOf(Spec::andX( + Spec::isNull('shortUrl'), + new InDateRange($this->dateRange), + )); } } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 00b558d4..b6c23699 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; use Cake\Chronos\Chronos; +use ReflectionObject; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -214,6 +215,75 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(3, $this->repo->countOrphanVisits()); } + /** @test */ + public function findOrphanVisitsReturnsExpectedResult(): void + { + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => ''])); + $this->getEntityManager()->persist($shortUrl); + $this->createVisitsForShortUrl($shortUrl, 7); + + for ($i = 0; $i < 6; $i++) { + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forBasePath(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forInvalidShortUrl(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forRegularNotFound(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + } + + $this->getEntityManager()->flush(); + + self::assertCount(18, $this->repo->findOrphanVisits()); + self::assertCount(5, $this->repo->findOrphanVisits(null, 5)); + self::assertCount(10, $this->repo->findOrphanVisits(null, 15, 8)); + self::assertCount(9, $this->repo->findOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')), 15)); + self::assertCount(2, $this->repo->findOrphanVisits( + DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + 6, + 4, + )); + self::assertCount(3, $this->repo->findOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01')))); + } + + /** @test */ + public function countOrphanVisitsReturnsExpectedResult(): void + { + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => ''])); + $this->getEntityManager()->persist($shortUrl); + $this->createVisitsForShortUrl($shortUrl, 7); + + for ($i = 0; $i < 6; $i++) { + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forBasePath(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forInvalidShortUrl(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + $this->getEntityManager()->persist($this->setDateOnVisit( + Visit::forRegularNotFound(Visitor::emptyInstance()), + Chronos::parse(sprintf('2020-01-0%s', $i + 1)), + )); + } + + $this->getEntityManager()->flush(); + + self::assertEquals(18, $this->repo->countOrphanVisits()); + self::assertEquals(18, $this->repo->countOrphanVisits(DateRange::emptyInstance())); + self::assertEquals(9, $this->repo->countOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')))); + self::assertEquals(6, $this->repo->countOrphanVisits( + DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + )); + self::assertEquals(3, $this->repo->countOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01')))); + } + private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array { $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ @@ -243,13 +313,22 @@ class VisitRepositoryTest extends DatabaseTestCase private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void { for ($i = 0; $i < $amount; $i++) { - $visit = new Visit( - $shortUrl, - Visitor::emptyInstance(), - true, + $visit = $this->setDateOnVisit( + Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), Chronos::parse(sprintf('2016-01-0%s', $i + 1)), ); + $this->getEntityManager()->persist($visit); } } + + private function setDateOnVisit(Visit $visit, Chronos $date): Visit + { + $ref = new ReflectionObject($visit); + $dateProp = $ref->getProperty('date'); + $dateProp->setAccessible(true); + $dateProp->setValue($visit, $date); + + return $visit; + } } From dcf2526aad541a169f90e7cf990e6b33844ece48 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 22:03:03 +0100 Subject: [PATCH 085/115] Documented swagger for new orphan visits endpoint --- docs/swagger/definitions/OrphanVisit.json | 23 ++++ docs/swagger/definitions/Visit.json | 1 + docs/swagger/paths/v2_visits_orphan.json | 141 ++++++++++++++++++++++ docs/swagger/swagger.json | 3 + 4 files changed, 168 insertions(+) create mode 100644 docs/swagger/definitions/OrphanVisit.json create mode 100644 docs/swagger/paths/v2_visits_orphan.json diff --git a/docs/swagger/definitions/OrphanVisit.json b/docs/swagger/definitions/OrphanVisit.json new file mode 100644 index 00000000..04d8386d --- /dev/null +++ b/docs/swagger/definitions/OrphanVisit.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "required": ["visitedUrl", "type"], + "allOf": [{ + "$ref": "./Visit.json" + }], + "properties": { + "visitedUrl": { + "type": "string", + "nullable": true, + "description": "The originally visited URL that triggered the tracking of this visit" + }, + "type": { + "type": "string", + "enum": [ + "invalid_short_url", + "base_url", + "regular_404" + ], + "description": "Tells the type of orphan visit" + } + } +} diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index 9e1eb5b5..e004e4fe 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -1,5 +1,6 @@ { "type": "object", + "required": ["referer", "date", "userAgent", "visitLocation"], "properties": { "referer": { "type": "string", diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json new file mode 100644 index 00000000..683f40ec --- /dev/null +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -0,0 +1,141 @@ +{ + "get": { + "operationId": "getOrphanVisits", + "tags": [ + "Visits" + ], + "summary": "List orphan visits", + "description": "Get the list of visits to invalid short URLs, the base URL or any other 404.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "name": "startDate", + "in": "query", + "description": "The date (in ISO-8601 format) from which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endDate", + "in": "query", + "description": "The date (in ISO-8601 format) until which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page to display. Defaults to 1", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The amount of items to return on every page. Defaults to all the items", + "required": false, + "schema": { + "type": "number" + } + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "List of visits.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visits": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../definitions/OrphanVisit.json" + } + }, + "pagination": { + "$ref": "../definitions/Pagination.json" + } + } + } + } + } + } + }, + "examples": { + "application/json": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "visitedUrl": "https://doma.in", + "type": "base_url" + }, + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "visitedUrl": "https://doma.in/foo", + "type": "invalid_short_url" + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "visitedUrl": "https://doma.in/foo/bar/baz", + "type": "regular_404" + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 + } + } + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index dc834905..21547f90 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -95,6 +95,9 @@ "/rest/v{version}/tags/{tag}/visits": { "$ref": "paths/v2_tags_{tag}_visits.json" }, + "/rest/v{version}/visits/orphan": { + "$ref": "paths/v2_visits_orphan.json" + }, "/rest/v{version}/domains": { "$ref": "paths/v2_domains.json" From 5d98316c4e95bde5d32944e1a70e5ea41bd0068a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 22:11:09 +0100 Subject: [PATCH 086/115] Created new REST API action to list orphan visits --- module/Core/config/dependencies.config.php | 12 ++++-- module/Core/functions/functions.php | 21 +++++++++ module/Core/src/Entity/Visit.php | 10 +++++ module/Core/src/Model/VisitsParams.php | 4 +- .../Adapter/OrphanVisitsPaginatorAdapter.php | 30 +++++++++++++ .../OrphanVisitDataTransformer.php | 24 +++++++++++ module/Core/src/Visit/VisitsStatsHelper.php | 28 +++++++++--- .../src/Visit/VisitsStatsHelperInterface.php | 5 +++ module/Rest/config/dependencies.config.php | 5 +++ module/Rest/config/routes.config.php | 1 + .../src/Action/Visit/OrphanVisitsAction.php | 43 +++++++++++++++++++ 11 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php create mode 100644 module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php create mode 100644 module/Rest/src/Action/Visit/OrphanVisitsAction.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 50669f66..43586e16 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -26,16 +26,20 @@ return [ Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, - Visit\VisitsTracker::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class, - Visit\VisitLocator::class => ConfigAbstractFactory::class, - Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, - Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class, + + Tag\TagService::class => ConfigAbstractFactory::class, + Domain\DomainService::class => ConfigAbstractFactory::class, + Visit\VisitsTracker::class => ConfigAbstractFactory::class, + Visit\VisitLocator::class => ConfigAbstractFactory::class, + Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, + Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class, + Util\UrlValidator::class => ConfigAbstractFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index f9a67e3d..00954049 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -9,6 +9,7 @@ use DateTimeInterface; use Fig\Http\Message\StatusCodeInterface; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; +use Shlinkio\Shlink\Common\Util\DateRange; use function Functional\reduce_left; use function is_array; @@ -44,6 +45,26 @@ function parseDateFromQuery(array $query, string $dateName): ?Chronos return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]); } +function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange +{ + $startDate = parseDateFromQuery($query, $startDateName); + $endDate = parseDateFromQuery($query, $endDateName); + + if ($startDate === null && $endDate === null) { + return DateRange::emptyInstance(); + } + + if ($startDate !== null && $endDate !== null) { + return DateRange::withStartAndEndDate($startDate, $endDate); + } + + if ($startDate !== null) { + return DateRange::withStartDate($startDate); + } + + return DateRange::withEndDate($endDate); +} + /** * @param string|DateTimeInterface|Chronos|null $date */ diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index efcf5d8f..61739dec 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -109,6 +109,16 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->shortUrl === null; } + public function visitedUrl(): ?string + { + return $this->visitedUrl; + } + + public function type(): string + { + return $this->type; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index 041aed9f..b579239b 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Common\Util\DateRange; -use function Shlinkio\Shlink\Core\parseDateFromQuery; +use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; final class VisitsParams { @@ -36,7 +36,7 @@ final class VisitsParams public static function fromRawData(array $query): self { return new self( - new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')), + parseDateRangeFromQuery($query, 'startDate', 'endDate'), (int) ($query['page'] ?? 1), isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php new file mode 100644 index 00000000..7167b9e7 --- /dev/null +++ b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -0,0 +1,30 @@ +repo = $repo; + $this->params = $params; + } + + protected function doCount(): int + { + return $this->repo->countOrphanVisits($this->params->getDateRange()); + } + + public function getSlice($offset, $length): iterable // phpcs:ignore + { + return $this->repo->findOrphanVisits($this->params->getDateRange(), $length, $offset); + } +} diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php new file mode 100644 index 00000000..9f4842f5 --- /dev/null +++ b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php @@ -0,0 +1,24 @@ +jsonSerialize(); + $serializedVisit['visitedUrl'] = $visit->visitedUrl(); + $serializedVisit['type'] = $visit->type(); + + return $serializedVisit; + } +} diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 0cb58897..61d879fd 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; +use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; @@ -13,6 +14,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; @@ -58,11 +60,8 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); - $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); - return $paginator; + return $this->createPaginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec), $params); } /** @@ -79,9 +78,26 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); + + return $this->createPaginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey), $params); + } + + /** + * @return Visit[]|Paginator + */ + public function orphanVisits(VisitsParams $params): Paginator + { + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + + return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params); + } + + private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator + { + $paginator = new Paginator($adapter); $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + ->setCurrentPage($params->getPage()); return $paginator; } diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index a67c8dcd..d2bf6032 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -32,4 +32,9 @@ interface VisitsStatsHelperInterface * @throws TagNotFoundException */ public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + + /** + * @return Visit[]|Paginator + */ + public function orphanVisits(VisitsParams $params): Paginator; } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 5b68ada7..e1a869df 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -34,6 +34,7 @@ return [ Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class, Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, @@ -69,6 +70,10 @@ return [ Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\Visit\OrphanVisitsAction::class => [ + Visit\VisitsStatsHelper::class, + Visit\Transformer\OrphanVisitDataTransformer::class, + ], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], Action\Tag\ListTagsAction::class => [TagService::class], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index a5382c38..9b09a266 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -34,6 +34,7 @@ return [ Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\TagVisitsAction::getRouteDef(), Action\Visit\GlobalVisitsAction::getRouteDef(), + Action\Visit\OrphanVisitsAction::getRouteDef(), // Tags Action\Tag\ListTagsAction::getRouteDef(), diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php new file mode 100644 index 00000000..7a65b920 --- /dev/null +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -0,0 +1,43 @@ +visitsHelper = $visitsHelper; + $this->orphanVisitTransformer = $orphanVisitTransformer; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $params = VisitsParams::fromRawData($request->getQueryParams()); + $visits = $this->visitsHelper->orphanVisits($params); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits, $this->orphanVisitTransformer), + ]); + } +} From bd9ec53e7b8b8aa7322bdcee5da25ef0b8e29ea0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 22:40:40 +0100 Subject: [PATCH 087/115] Added test for VisitsStatsHelper::orphanVisits --- .../Core/test/Visit/VisitsStatsHelperTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 6fefd1d0..de2a3534 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -27,6 +27,7 @@ use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use function count; use function Functional\map; use function range; @@ -148,4 +149,21 @@ class VisitsStatsHelperTest extends TestCase $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } + + /** @test */ + public function orphanVisitsAreReturnedAsExpected(): void + { + $list = map(range(0, 3), fn () => Visit::forBasePath(Visitor::emptyInstance())); + $repo = $this->prophesize(VisitRepository::class); + $countVisits = $repo->countOrphanVisits(Argument::type(DateRange::class))->willReturn(count($list)); + $listVisits = $repo->findOrphanVisits(Argument::type(DateRange::class), Argument::cetera())->willReturn($list); + $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); + + $paginator = $this->helper->orphanVisits(new VisitsParams()); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $listVisits->shouldHaveBeenCalledOnce(); + $countVisits->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } } From d5794a3dcb50d0a3d6fcd1e6caab0b92c54ae5e6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 22:52:44 +0100 Subject: [PATCH 088/115] Created OrphanVisitDataTransformerTest --- .../OrphanVisitDataTransformerTest.php | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php new file mode 100644 index 00000000..cf36c052 --- /dev/null +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -0,0 +1,82 @@ +transformer = new OrphanVisitDataTransformer(); + } + + /** + * @test + * @dataProvider provideVisits + */ + public function visitsAreParsedAsExpected(Visit $visit, array $expectedResult): void + { + $result = $this->transformer->transform($visit); + + self::assertEquals($expectedResult, $result); + } + + public function provideVisits(): iterable + { + yield 'base path visit' => [ + $visit = Visit::forBasePath(Visitor::emptyInstance()), + [ + 'referer' => '', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => '', + 'visitLocation' => null, + 'visitedUrl' => '', + 'type' => Visit::TYPE_BASE_URL, + ], + ]; + yield 'invalid short url visit' => [ + $visit = Visit::forInvalidShortUrl(Visitor::fromRequest( + ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'foo') + ->withHeader('Referer', 'bar') + ->withUri(new Uri('https://example.com/foo')), + )), + [ + 'referer' => 'bar', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => 'foo', + 'visitLocation' => null, + 'visitedUrl' => 'https://example.com/foo', + 'type' => Visit::TYPE_INVALID_SHORT_URL, + ], + ]; + yield 'regular 404 visit' => [ + $visit = Visit::forRegularNotFound( + Visitor::fromRequest( + ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent') + ->withHeader('Referer', 'referer') + ->withUri(new Uri('https://doma.in/foo/bar')), + ), + )->locate($location = new VisitLocation(Location::emptyInstance())), + [ + 'referer' => 'referer', + 'date' => $visit->getDate()->toAtomString(), + 'userAgent' => 'user-agent', + 'visitLocation' => $location, + 'visitedUrl' => 'https://doma.in/foo/bar', + 'type' => Visit::TYPE_REGULAR_404, + ], + ]; + } +} From 3497165ebd3d48f319c3ddb1cad7ad358bb29aa1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 23:34:29 +0100 Subject: [PATCH 089/115] Created OrphanVisitsPaginatorAdapterTest --- .../OrphanVisitsPaginatorAdapterTest.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php diff --git a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php new file mode 100644 index 00000000..6b28aa68 --- /dev/null +++ b/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -0,0 +1,65 @@ +repo = $this->prophesize(VisitRepositoryInterface::class); + $this->params = VisitsParams::fromRawData([]); + $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params); + } + + /** @test */ + public function countDelegatesToRepository(): void + { + $expectedCount = 5; + $repoCount = $this->repo->countOrphanVisits($this->params->getDateRange())->willReturn($expectedCount); + + $result = $this->adapter->getNbResults(); + + self::assertEquals($expectedCount, $result); + $repoCount->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideLimitAndOffset + */ + public function getSliceDelegatesToRepository(int $limit, int $offset): void + { + $visitor = Visitor::emptyInstance(); + $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; + $repoFind = $this->repo->findOrphanVisits($this->params->getDateRange(), $limit, $offset)->willReturn($list); + + $result = $this->adapter->getSlice($offset, $limit); + + self::assertEquals($list, $result); + $repoFind->shouldHaveBeenCalledOnce(); + } + + public function provideLimitAndOffset(): iterable + { + yield [1, 5]; + yield [10, 4]; + yield [30, 18]; + } +} From 82f4e22f691439a083cb0115af798f53bd153560 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 23:41:51 +0100 Subject: [PATCH 090/115] Created OrphanVisitsActionTest --- .../Action/Visit/OrphanVisitsActionTest.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 module/Rest/test/Action/Visit/OrphanVisitsActionTest.php diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php new file mode 100644 index 00000000..36273d09 --- /dev/null +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -0,0 +1,58 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->orphanVisitTransformer = $this->prophesize(DataTransformerInterface::class); + + $this->action = new OrphanVisitsAction($this->visitsHelper->reveal(), $this->orphanVisitTransformer->reveal()); + } + + /** @test */ + public function requestIsHandled(): void + { + $visitor = Visitor::emptyInstance(); + $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; + $orphanVisits = $this->visitsHelper->orphanVisits(Argument::type(VisitsParams::class))->willReturn( + new Paginator(new ArrayAdapter($visits)), + ); + $transform = $this->orphanVisitTransformer->transform(Argument::type(Visit::class))->willReturn([]); + + $response = $this->action->handle(ServerRequestFactory::fromGlobals()); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(200, $response->getStatusCode()); + $orphanVisits->shouldHaveBeenCalledOnce(); + $transform->shouldHaveBeenCalledTimes(count($visits)); + } +} From a18486cc2e3e65256c84e69ec96fc0a511282992 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 9 Feb 2021 23:56:46 +0100 Subject: [PATCH 091/115] Created OrphanVisits API test --- .../Rest/test-api/Action/OrphanVisitsTest.php | 59 +++++++++++++++++++ .../Rest/test-api/Fixtures/VisitsFixture.php | 29 +++++++-- .../Action/Visit/OrphanVisitsActionTest.php | 1 - 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 module/Rest/test-api/Action/OrphanVisitsTest.php diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php new file mode 100644 index 00000000..ea890f9f --- /dev/null +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -0,0 +1,59 @@ + 'https://doma.in/foo', + 'date' => '2020-03-01T00:00:00+00:00', + 'userAgent' => 'shlink-tests-agent', + 'visitLocation' => null, + 'visitedUrl' => 'foo.com', + 'type' => 'invalid_short_url', + + ]; + private const REGULAR_NOT_FOUND = [ + 'referer' => 'https://doma.in/foo/bar', + 'date' => '2020-02-01T00:00:00+00:00', + 'userAgent' => 'shlink-tests-agent', + 'visitLocation' => null, + 'visitedUrl' => '', + 'type' => 'regular_404', + ]; + private const BASE_URL = [ + 'referer' => 'https://doma.in', + 'date' => '2020-01-01T00:00:00+00:00', + 'userAgent' => 'shlink-tests-agent', + 'visitLocation' => null, + 'visitedUrl' => '', + 'type' => 'base_url', + ]; + + /** + * @test + * @dataProvider provideQueries + */ + public function properVisitsAreReturnedBasedInQuery(array $query, int $expectedAmount, array $expectedVisits): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [RequestOptions::QUERY => $query]); + $payload = $this->getJsonResponsePayload($resp); + $visits = $payload['visits']['data'] ?? []; + + self::assertEquals(3, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertCount($expectedAmount, $visits); + self::assertEquals($expectedVisits, $visits); + } + + public function provideQueries(): iterable + { + yield 'all data' => [[], 3, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND, self::BASE_URL]]; + yield 'limit items' => [['itemsPerPage' => 2], 2, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND]]; + yield 'limit items and page' => [['itemsPerPage' => 2, 'page' => 2], 1, [self::BASE_URL]]; + } +} diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 9fb53ac1..412c79d5 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Fixtures; +use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; +use ReflectionObject; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Visitor; @@ -47,14 +49,29 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), ); - $manager->persist(Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', ''))); - $manager->persist( + $manager->persist($this->setVisitDate( + Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', '')), + '2020-01-01', + )); + $manager->persist($this->setVisitDate( Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://doma.in/foo/bar', '1.2.3.4', '')), - ); - $manager->persist( - Visit::forInvalidShortUrl(new Visitor('shlink-tests-agent', 'https://doma.in/foo', '1.2.3.4', '')), - ); + '2020-02-01', + )); + $manager->persist($this->setVisitDate( + Visit::forInvalidShortUrl(new Visitor('shlink-tests-agent', 'https://doma.in/foo', '1.2.3.4', 'foo.com')), + '2020-03-01', + )); $manager->flush(); } + + private function setVisitDate(Visit $visit, string $date): Visit + { + $ref = new ReflectionObject($visit); + $dateProp = $ref->getProperty('date'); + $dateProp->setAccessible(true); + $dateProp->setValue($visit, Chronos::parse($date)); + + return $visit; + } } diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index 36273d09..9fec7e1f 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\Visit; From 4b73bd907eb1faf3abb9ed322333f7da909c2025 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 10 Feb 2021 08:23:29 +0100 Subject: [PATCH 092/115] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b34f679..e11c74cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers. * [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used. * [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int. +* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry). ## [2.5.2] - 2021-01-24 From 2fc6fb0a9a9e022ee85190f4b8fd73406b1452c4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 10 Feb 2021 20:09:25 +0100 Subject: [PATCH 093/115] Added option to disable orphan visitstracking --- config/autoload/url-shortener.global.php | 1 + docker/config/shlink_in_docker.local.php | 1 + module/Core/config/dependencies.config.php | 2 +- .../Core/src/Options/UrlShortenerOptions.php | 25 ++++++++++++++-- module/Core/src/Visit/VisitsTracker.php | 30 ++++++++++++++----- module/Core/test/Visit/VisitsTrackerTest.php | 27 ++++++++++++++++- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 015d459e..5d31ae2d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -20,6 +20,7 @@ return [ 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, 'auto_resolve_titles' => false, + 'track_orphan_visits' => true, ], ]; diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 40173d69..4ddd52e5 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -126,6 +126,7 @@ return [ 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), + 'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true), ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 43586e16..1b83ad7d 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -85,7 +85,7 @@ return [ Visit\VisitsTracker::class => [ 'em', EventDispatcherInterface::class, - 'config.url_shortener.anonymize_remote_addr', + Options\UrlShortenerOptions::class, ], Service\ShortUrlService::class => [ 'em', diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index ebedbf97..e1956203 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -19,6 +19,8 @@ class UrlShortenerOptions extends AbstractOptions private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private bool $autoResolveTitles = false; + private bool $anonymizeRemoteAddr = true; + private bool $trackOrphanVisits = true; public function isUrlValidationEnabled(): bool { @@ -62,9 +64,28 @@ class UrlShortenerOptions extends AbstractOptions return $this->autoResolveTitles; } - protected function setAutoResolveTitles(bool $autoResolveTitles): self + protected function setAutoResolveTitles(bool $autoResolveTitles): void { $this->autoResolveTitles = $autoResolveTitles; - return $this; + } + + public function anonymizeRemoteAddr(): bool + { + return $this->anonymizeRemoteAddr; + } + + protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void + { + $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; + } + + public function trackOrphanVisits(): bool + { + return $this->trackOrphanVisits; + } + + protected function setTrackOrphanVisits(bool $trackOrphanVisits): void + { + $this->trackOrphanVisits = $trackOrphanVisits; } } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 48157e3b..306da7a9 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -10,41 +10,57 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; class VisitsTracker implements VisitsTrackerInterface { private ORM\EntityManagerInterface $em; private EventDispatcherInterface $eventDispatcher; - private bool $anonymizeRemoteAddr; + private UrlShortenerOptions $options; public function __construct( ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher, - bool $anonymizeRemoteAddr + UrlShortenerOptions $options ) { $this->em = $em; $this->eventDispatcher = $eventDispatcher; - $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; + $this->options = $options; } public function track(ShortUrl $shortUrl, Visitor $visitor): void { - $this->trackVisit(Visit::forValidShortUrl($shortUrl, $visitor, $this->anonymizeRemoteAddr), $visitor); + $this->trackVisit( + Visit::forValidShortUrl($shortUrl, $visitor, $this->options->anonymizeRemoteAddr()), + $visitor, + ); } public function trackInvalidShortUrlVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->anonymizeRemoteAddr), $visitor); + if (! $this->options->trackOrphanVisits()) { + return; + } + + $this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->options->anonymizeRemoteAddr()), $visitor); } public function trackBaseUrlVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forBasePath($visitor, $this->anonymizeRemoteAddr), $visitor); + if (! $this->options->trackOrphanVisits()) { + return; + } + + $this->trackVisit(Visit::forBasePath($visitor, $this->options->anonymizeRemoteAddr()), $visitor); } public function trackRegularNotFoundVisit(Visitor $visitor): void { - $this->trackVisit(Visit::forRegularNotFound($visitor, $this->anonymizeRemoteAddr), $visitor); + if (! $this->options->trackOrphanVisits()) { + return; + } + + $this->trackVisit(Visit::forRegularNotFound($visitor, $this->options->anonymizeRemoteAddr()), $visitor); } private function trackVisit(Visit $visit, Visitor $visitor): void diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index fd6e341f..118ebc06 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Visit\VisitsTracker; class VisitsTrackerTest extends TestCase @@ -23,13 +24,15 @@ class VisitsTrackerTest extends TestCase private VisitsTracker $visitsTracker; private ObjectProphecy $em; private ObjectProphecy $eventDispatcher; + private UrlShortenerOptions $options; public function setUp(): void { $this->em = $this->prophesize(EntityManager::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->options = new UrlShortenerOptions(); - $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); + $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options); } /** @@ -53,4 +56,26 @@ class VisitsTrackerTest extends TestCase yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; } + + /** + * @test + * @dataProvider provideOrphanTrackingMethodNames + */ + public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void + { + $this->options->trackOrphanVisits = false; + + $this->visitsTracker->{$method}(Visitor::emptyInstance()); + + $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->flush()->shouldNotHaveBeenCalled(); + } + + public function provideOrphanTrackingMethodNames(): iterable + { + yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit']; + yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit']; + yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit']; + } } From bd09b1571a34fbd45ffb603f1fcea89631e009bc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 10 Feb 2021 20:39:37 +0100 Subject: [PATCH 094/115] Updated shlink-installer with support for orphan visits tracking option --- composer.json | 2 +- config/autoload/installer.global.php | 1 + config/autoload/url-shortener.global.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ec9a028d..f4eedb4a 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.2", - "shlinkio/shlink-installer": "dev-develop#1ed5ac8 as 5.4", + "shlinkio/shlink-installer": "dev-develop#c489d3f as 5.4", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 7a355dbe..d18f31f4 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -41,6 +41,7 @@ return [ Option\UrlShortener\RedirectStatusCodeConfigOption::class, Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class, + Option\UrlShortener\OrphanVisitsTrackingConfigOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 5d31ae2d..3751b1e9 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -13,7 +13,7 @@ return [ 'schema' => 'https', 'hostname' => '', ], - 'validate_url' => false, + 'validate_url' => false, // Deprecated 'anonymize_remote_addr' => true, 'visits_webhooks' => [], 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, From bec467c7031e3be7c0d818eda29987476d61c82f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 11 Feb 2021 13:52:36 +0100 Subject: [PATCH 095/115] Fixed issue with swoole 4.6.3 --- config/autoload/middleware-pipeline.global.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 9f8cc729..2eb009cb 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -10,12 +10,16 @@ use Mezzio\ProblemDetails; use Mezzio\Router; use PhpMiddleware\RequestId\RequestIdMiddleware; +use function extension_loaded; + return [ 'middleware_pipeline' => [ 'error-handler' => [ 'middleware' => [ - Helper\ContentLengthMiddleware::class, + // For some reason, with swoole 4.6.3, piping this middleware makes requests to have incomplete body or + // never finish loading. Disabling it for swoole fixes it as it already calculates the header on itself + ...extension_loaded('swoole') ? [] : [Helper\ContentLengthMiddleware::class], ErrorHandler::class, ], ], From 7d6d8e3a68f85920937ab1208a19f9d89b1a907c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 11 Feb 2021 22:12:38 +0100 Subject: [PATCH 096/115] Added support to publish orphan visits in mercure --- docs/async-api/async-api.json | 57 +++++++++++++++++++ module/Core/config/dependencies.config.php | 5 +- .../Core/src/EventDispatcher/LocateVisit.php | 4 +- .../EventDispatcher/NotifyVisitToMercure.php | 21 ++++++- .../src/Mercure/MercureUpdatesGenerator.php | 24 ++++++-- .../MercureUpdatesGeneratorInterface.php | 2 + .../test/EventDispatcher/LocateVisitTest.php | 17 ++---- .../NotifyVisitToMercureTest.php | 49 ++++++++++++++-- .../Mercure/MercureUpdatesGeneratorTest.php | 36 +++++++++++- 9 files changed, 187 insertions(+), 28 deletions(-) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 5279ce91..df9bc6d6 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -58,6 +58,23 @@ } } } + }, + "http://shlink.io/new-orphan-visit": { + "subscribe": { + "summary": "Receive information about any new orphan visit.", + "operationId": "newOrphanVisit", + "message": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "visit": { + "$ref": "#/components/schemas/OrphanVisit" + } + } + } + } + } } }, "components": { @@ -179,6 +196,46 @@ } } }, + "OrphanVisit": { + "allOf": [ + {"$ref": "#/components/schemas/Visit"}, + { + "type": "object", + "properties": { + "visitedUrl": { + "type": "string", + "nullable": true, + "description": "The originally visited URL that triggered the tracking of this visit" + }, + "type": { + "type": "string", + "enum": [ + "invalid_short_url", + "base_url", + "regular_404" + ], + "description": "Tells the type of orphan visit" + } + } + } + ], + "example": { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "visitedUrl": "https://doma.in", + "type": "base_url" + } + }, "VisitLocation": { "type": "object", "properties": { diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 1b83ad7d..479b497a 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -133,7 +133,10 @@ return [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], - Mercure\MercureUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], + Mercure\MercureUpdatesGenerator::class => [ + ShortUrl\Transformer\ShortUrlDataTransformer::class, + Visit\Transformer\OrphanVisitDataTransformer::class, + ], Importer\ImportedLinksProcessor::class => [ 'em', diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 5e3baf74..32da6060 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -58,9 +58,7 @@ class LocateVisit $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); } - if (! $visit->isOrphan()) { - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); - } + $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } private function downloadOrUpdateGeoLiteDb(string $visitId): bool diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php index 33aab7af..0cf438ed 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -10,8 +10,11 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Update; use Throwable; +use function Functional\each; + class NotifyVisitToMercure { private PublisherInterface $publisher; @@ -45,12 +48,26 @@ class NotifyVisitToMercure } try { - ($this->publisher)($this->updatesGenerator->newShortUrlVisitUpdate($visit)); - ($this->publisher)($this->updatesGenerator->newVisitUpdate($visit)); + each($this->determineUpdatesForVisit($visit), fn (Update $update) => ($this->publisher)($update)); } catch (Throwable $e) { $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ 'e' => $e, ]); } } + + /** + * @return Update[] + */ + private function determineUpdatesForVisit(Visit $visit): array + { + if ($visit->isOrphan()) { + return [$this->updatesGenerator->newOrphanVisitUpdate($visit)]; + } + + return [ + $this->updatesGenerator->newShortUrlVisitUpdate($visit), + $this->updatesGenerator->newVisitUpdate($visit), + ]; + } } diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index 9a0a28f3..23b3796c 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -16,29 +16,41 @@ use const JSON_THROW_ON_ERROR; final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface { private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; + private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit'; - private DataTransformerInterface $transformer; + private DataTransformerInterface $shortUrlTransformer; + private DataTransformerInterface $orphanVisitTransformer; - public function __construct(DataTransformerInterface $transformer) - { - $this->transformer = $transformer; + public function __construct( + DataTransformerInterface $shortUrlTransformer, + DataTransformerInterface $orphanVisitTransformer + ) { + $this->shortUrlTransformer = $shortUrlTransformer; + $this->orphanVisitTransformer = $orphanVisitTransformer; } public function newVisitUpdate(Visit $visit): Update { return new Update(self::NEW_VISIT_TOPIC, $this->serialize([ - 'shortUrl' => $this->transformer->transform($visit->getShortUrl()), + 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), 'visit' => $visit, ])); } + public function newOrphanVisitUpdate(Visit $visit): Update + { + return new Update(self::NEW_ORPHAN_VISIT_TOPIC, $this->serialize([ + 'visit' => $this->orphanVisitTransformer->transform($visit), + ])); + } + public function newShortUrlVisitUpdate(Visit $visit): Update { $shortUrl = $visit->getShortUrl(); $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode()); return new Update($topic, $this->serialize([ - 'shortUrl' => $this->transformer->transform($shortUrl), + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), 'visit' => $visit, ])); } diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php index d433d9ad..951e805c 100644 --- a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php +++ b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php @@ -11,5 +11,7 @@ interface MercureUpdatesGeneratorInterface { public function newVisitUpdate(Visit $visit): Update; + public function newOrphanVisitUpdate(Visit $visit): Update; + public function newShortUrlVisitUpdate(Visit $visit): Update; } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index af21e3dc..081f0f86 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -136,11 +136,8 @@ class LocateVisitTest extends TestCase * @test * @dataProvider provideIpAddresses */ - public function locatableVisitsResolveToLocation( - Visit $visit, - ?string $originalIpAddress, - int $expectedDispatchCalls - ): void { + public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void + { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = new UrlVisited('123', $originalIpAddress); @@ -159,7 +156,7 @@ class LocateVisitTest extends TestCase $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledTimes($expectedDispatchCalls); + $dispatch->shouldHaveBeenCalledOnce(); } public function provideIpAddresses(): iterable @@ -167,16 +164,14 @@ class LocateVisitTest extends TestCase yield 'no original IP address' => [ Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), null, - 1, ]; yield 'original IP address' => [ Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', - 1, ]; - yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; - yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; - yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4', 0]; + yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; + yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; + yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; } /** @test */ diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index 1180d05c..f323a155 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -57,10 +57,9 @@ class NotifyVisitToMercureTest extends TestCase $logDebug = $this->logger->debug(Argument::cetera()); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate( Argument::type(Visit::class), - )->willReturn(new Update('', '')); - $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class))->willReturn( - new Update('', ''), ); + $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class)); + $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class)); $publish = $this->publisher->__invoke(Argument::type(Update::class)); ($this->listener)(new VisitLocated($visitId)); @@ -70,6 +69,7 @@ class NotifyVisitToMercureTest extends TestCase $logDebug->shouldNotHaveBeenCalled(); $buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled(); $buildNewVisitUpdate->shouldNotHaveBeenCalled(); + $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); $publish->shouldNotHaveBeenCalled(); } @@ -84,6 +84,7 @@ class NotifyVisitToMercureTest extends TestCase $logWarning = $this->logger->warning(Argument::cetera()); $logDebug = $this->logger->debug(Argument::cetera()); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); + $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); $publish = $this->publisher->__invoke($update); @@ -94,6 +95,7 @@ class NotifyVisitToMercureTest extends TestCase $logDebug->shouldNotHaveBeenCalled(); $buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce(); $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); + $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); $publish->shouldHaveBeenCalledTimes(2); } @@ -111,6 +113,7 @@ class NotifyVisitToMercureTest extends TestCase 'e' => $e, ]); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); + $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); $publish = $this->publisher->__invoke($update)->willThrow($e); @@ -120,7 +123,45 @@ class NotifyVisitToMercureTest extends TestCase $logWarning->shouldNotHaveBeenCalled(); $logDebug->shouldHaveBeenCalledOnce(); $buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce(); - $buildNewVisitUpdate->shouldNotHaveBeenCalled(); + $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); + $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); $publish->shouldHaveBeenCalledOnce(); } + + /** + * @test + * @dataProvider provideOrphanVisits + */ + public function notificationsAreSentForOrphanVisits(Visit $visit): void + { + $visitId = '123'; + $update = new Update('', ''); + + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $logWarning = $this->logger->warning(Argument::cetera()); + $logDebug = $this->logger->debug(Argument::cetera()); + $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); + $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); + $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); + $publish = $this->publisher->__invoke($update); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldNotHaveBeenCalled(); + $logDebug->shouldNotHaveBeenCalled(); + $buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled(); + $buildNewVisitUpdate->shouldNotHaveBeenCalled(); + $buildNewOrphanVisitUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideOrphanVisits(): iterable + { + $visitor = Visitor::emptyInstance(); + + yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; + yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; + yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + } } diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index 9e4b418e..b4361ca5 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; +use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use function Shlinkio\Shlink\Common\json_decode; @@ -21,7 +22,10 @@ class MercureUpdatesGeneratorTest extends TestCase public function setUp(): void { - $this->generator = new MercureUpdatesGenerator(new ShortUrlDataTransformer(new ShortUrlStringifier([]))); + $this->generator = new MercureUpdatesGenerator( + new ShortUrlDataTransformer(new ShortUrlStringifier([])), + new OrphanVisitDataTransformer(), + ); } /** @@ -70,4 +74,34 @@ class MercureUpdatesGeneratorTest extends TestCase yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit', 'the cool title']; yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo', null]; } + + /** + * @test + * @dataProvider provideOrphanVisits + */ + public function orphanVisitIsProperlySerializedIntoUpdate(Visit $orphanVisit): void + { + $update = $this->generator->newOrphanVisitUpdate($orphanVisit); + + self::assertEquals(['https://shlink.io/new-orphan-visit'], $update->getTopics()); + self::assertEquals([ + 'visit' => [ + 'referer' => '', + 'userAgent' => '', + 'visitLocation' => null, + 'date' => $orphanVisit->getDate()->toAtomString(), + 'visitedUrl' => $orphanVisit->visitedUrl(), + 'type' => $orphanVisit->type(), + ], + ], json_decode($update->getData())); + } + + public function provideOrphanVisits(): iterable + { + $visitor = Visitor::emptyInstance(); + + yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; + yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; + yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + } } From a0d8d237d7c03ba9a42829f169d63905f0b927c6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 11 Feb 2021 22:23:30 +0100 Subject: [PATCH 097/115] Gitignored helper file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8cfea409..03b2790e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ data/shlink-tests.db data/GeoLite2-City.mmdb data/GeoLite2-City.mmdb.* docs/swagger-ui* +docs/mercure.html docker-compose.override.yml .phpunit.result.cache From cc68cb944f7cbfde54383e08156e5ba7866578e3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 11 Feb 2021 22:43:23 +0100 Subject: [PATCH 098/115] Updated changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e11c74cc..9f00b680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. * [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code. +* [#675](https://github.com/shlinkio/shlink/issues/1000) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits. + + This behavior is enabled by default, but you can opt out via env vars or config options. + + This new orphan visits can be consumed in these ways: + + * The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs. + * The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits. ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. From 9c48e6578dafef5728c308b666f9ea8a2f483fa7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Feb 2021 09:24:13 +0100 Subject: [PATCH 099/115] Removed mezzio-helpers and used ContentLengthMiddleware from shlink-common --- composer.json | 3 +-- config/autoload/middleware-pipeline.global.php | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index f4eedb4a..d03790a8 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,6 @@ "lstrojny/functional-php": "^1.15", "mezzio/mezzio": "^3.3", "mezzio/mezzio-fastroute": "^3.1", - "mezzio/mezzio-helpers": "^5.3", "mezzio/mezzio-problem-details": "^1.3", "mezzio/mezzio-swoole": "^3.1", "monolog/monolog": "^2.0", @@ -47,7 +46,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-main#62d4b84 as 3.5", + "shlinkio/shlink-common": "dev-main#2cf5e45 as 3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.2", diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index c879205f..c60e1ba7 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -5,22 +5,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink; use Laminas\Stratigility\Middleware\ErrorHandler; -use Mezzio\Helper; use Mezzio\ProblemDetails; use Mezzio\Router; use PhpMiddleware\RequestId\RequestIdMiddleware; use RKA\Middleware\IpAddress; - -use function extension_loaded; +use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware; return [ 'middleware_pipeline' => [ 'error-handler' => [ 'middleware' => [ - // For some reason, with swoole 4.6.3, piping this middleware makes requests to have incomplete body or - // never finish loading. Disabling it for swoole fixes it as it already calculates the header on itself - ...extension_loaded('swoole') ? [] : [Helper\ContentLengthMiddleware::class], + ContentLengthMiddleware::class, ErrorHandler::class, ], ], From d932f0a2047892bad561ceabb2646ec093b2deee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Feb 2021 22:59:40 +0100 Subject: [PATCH 100/115] Increased timeout on db commands to 10 minutes --- module/CLI/config/dependencies.config.php | 7 ++- .../Command/Db/AbstractDatabaseCommand.php | 17 +++--- .../src/Command/Db/CreateDatabaseCommand.php | 6 +-- .../src/Command/Util/LockedCommandConfig.php | 14 ++++- .../src/Command/Visit/LocateVisitsCommand.php | 2 +- module/CLI/src/Util/ProcessRunner.php | 54 +++++++++++++++++++ .../CLI/src/Util/ProcessRunnerInterface.php | 12 +++++ .../Command/Db/CreateDatabaseCommandTest.php | 9 ++-- .../Command/Db/MigrateDatabaseCommandTest.php | 9 ++-- .../Command/Visit/LocateVisitsCommandTest.php | 2 +- phpstan.neon | 1 - 11 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 module/CLI/src/Util/ProcessRunner.php create mode 100644 module/CLI/src/Util/ProcessRunnerInterface.php diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 7e224c33..80b26b8d 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -34,6 +34,8 @@ return [ PhpExecutableFinder::class => InvokableFactory::class, Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class, + Util\ProcessRunner::class => ConfigAbstractFactory::class, + ApiKey\RoleResolver::class => ConfigAbstractFactory::class, Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class, @@ -62,6 +64,7 @@ return [ ConfigAbstractFactory::class => [ Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY], + Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], ApiKey\RoleResolver::class => [DomainService::class], Command\ShortUrl\GenerateShortUrlCommand::class => [ @@ -97,14 +100,14 @@ return [ Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, - SymfonyCli\Helper\ProcessHelper::class, + Util\ProcessRunner::class, PhpExecutableFinder::class, Connection::class, NoDbNameConnectionFactory::SERVICE_NAME, ], Command\Db\MigrateDatabaseCommand::class => [ LockFactory::class, - SymfonyCli\Helper\ProcessHelper::class, + Util\ProcessRunner::class, PhpExecutableFinder::class, ], ], diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index 5e9374cf..e4515ab5 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -6,31 +6,34 @@ namespace Shlinkio\Shlink\CLI\Command\Db; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; -use Symfony\Component\Console\Helper\ProcessHelper; +use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; abstract class AbstractDatabaseCommand extends AbstractLockedCommand { - private ProcessHelper $processHelper; + private ProcessRunnerInterface $processRunner; private string $phpBinary; - public function __construct(LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder) - { + public function __construct( + LockFactory $locker, + ProcessRunnerInterface $processRunner, + PhpExecutableFinder $phpFinder + ) { parent::__construct($locker); - $this->processHelper = $processHelper; + $this->processRunner = $processRunner; $this->phpBinary = $phpFinder->find(false) ?: 'php'; } protected function runPhpCommand(OutputInterface $output, array $command): void { $command = [$this->phpBinary, ...$command, '--no-interaction']; - $this->processHelper->mustRun($output, $command); + $this->processRunner->run($output, $command); } protected function getLockConfig(): LockedCommandConfig { - return new LockedCommandConfig($this->getName(), true); + return LockedCommandConfig::blocking($this->getName()); } } diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index b8e88688..ca68f818 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Symfony\Component\Console\Helper\ProcessHelper; +use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -26,12 +26,12 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand public function __construct( LockFactory $locker, - ProcessHelper $processHelper, + ProcessRunnerInterface $processRunner, PhpExecutableFinder $phpFinder, Connection $conn, Connection $noDbNameConn ) { - parent::__construct($locker, $processHelper, $phpFinder); + parent::__construct($locker, $processRunner, $phpFinder); $this->regularConn = $conn; $this->noDbNameConn = $noDbNameConn; } diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockedCommandConfig.php index 8a217f85..8de204c5 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockedCommandConfig.php @@ -6,19 +6,29 @@ namespace Shlinkio\Shlink\CLI\Command\Util; final class LockedCommandConfig { - private const DEFAULT_TTL = 90.0; // 1.5 minutes + public const DEFAULT_TTL = 600.0; // 10 minutes private string $lockName; private bool $isBlocking; private float $ttl; - public function __construct(string $lockName, bool $isBlocking = false, float $ttl = self::DEFAULT_TTL) + private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL) { $this->lockName = $lockName; $this->isBlocking = $isBlocking; $this->ttl = $ttl; } + public static function blocking(string $lockName): self + { + return new self($lockName, true); + } + + public static function nonBlocking(string $lockName): self + { + return new self($lockName, false); + } + public function lockName(): string { return $this->lockName; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index bf1ac14b..67678d4d 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -208,6 +208,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat protected function getLockConfig(): LockedCommandConfig { - return new LockedCommandConfig($this->getName()); + return LockedCommandConfig::nonBlocking($this->getName()); } } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php new file mode 100644 index 00000000..4da5cb9f --- /dev/null +++ b/module/CLI/src/Util/ProcessRunner.php @@ -0,0 +1,54 @@ +helper = $helper; + } + + public function run(OutputInterface $output, array $cmd): void + { + if ($output instanceof ConsoleOutputInterface) { + $output = $output->getErrorOutput(); + } + + /** @var DebugFormatterHelper $formatter */ + $formatter = $this->helper->getHelperSet()->get('debug_formatter'); + $process = new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL); + + if ($output->isVeryVerbose()) { + $output->write( + $formatter->start(spl_object_hash($process), str_replace('<', '\\<', $process->getCommandLine())), + ); + } + + $callback = $output->isDebug() ? $this->helper->wrapCallback($output, $process) : null; + $process->mustRun($callback); + + if ($output->isVeryVerbose()) { + $message = $process->isSuccessful() ? 'Command ran successfully' : sprintf( + '%s Command did not run successfully', + $process->getExitCode(), + ); + $output->write($formatter->stop(spl_object_hash($process), $message, $process->isSuccessful())); + } + } +} diff --git a/module/CLI/src/Util/ProcessRunnerInterface.php b/module/CLI/src/Util/ProcessRunnerInterface.php new file mode 100644 index 00000000..c00a4691 --- /dev/null +++ b/module/CLI/src/Util/ProcessRunnerInterface.php @@ -0,0 +1,12 @@ +prophesize(PhpExecutableFinder::class); $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->prophesize(ProcessHelper::class); + $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); $this->schemaManager = $this->prophesize(AbstractSchemaManager::class); $this->databasePlatform = $this->prophesize(AbstractPlatform::class); @@ -113,12 +112,12 @@ class CreateDatabaseCommandTest extends TestCase $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); $listTables = $this->schemaManager->listTableNames()->willReturn([]); - $runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [ + $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ '/usr/local/bin/php', CreateDatabaseCommand::DOCTRINE_SCRIPT, CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND, '--no-interaction', - ], Argument::cetera())->willReturn(new Process([])); + ]); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 9875c2f6..d25f44f2 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -9,14 +9,13 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; +use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; -use Symfony\Component\Process\Process; class MigrateDatabaseCommandTest extends TestCase { @@ -37,7 +36,7 @@ class MigrateDatabaseCommandTest extends TestCase $phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class); $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->prophesize(ProcessHelper::class); + $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); $command = new MigrateDatabaseCommand( $locker->reveal(), @@ -53,12 +52,12 @@ class MigrateDatabaseCommandTest extends TestCase /** @test */ public function migrationsCommandIsRunWithProperVerbosity(): void { - $runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [ + $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ '/usr/local/bin/php', MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT, MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND, '--no-interaction', - ], Argument::cetera())->willReturn(new Process([])); + ]); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 236fac50..d5ee2982 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -52,7 +52,7 @@ class LocateVisitsCommandTest extends TestCase $this->lock->acquire(false)->willReturn(true); $this->lock->release()->will(function (): void { }); - $locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal()); + $locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal()); $command = new LocateVisitsCommand( $this->visitService->reveal(), diff --git a/phpstan.neon b/phpstan.neon index 969b00b4..80f1b083 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,5 +2,4 @@ parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false ignoreErrors: - - '#mustRun\(\)#' - '#If condition is always false#' From 4e00c950cc2d1fa13eaf3d663f2e6d8cbb93cb81 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Feb 2021 23:23:34 +0100 Subject: [PATCH 101/115] Created ProcessRunnerTest --- CHANGELOG.md | 1 + module/CLI/src/Util/ProcessRunner.php | 10 +- module/CLI/test/Util/ProcessRunnerTest.php | 106 +++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 module/CLI/test/Util/ProcessRunnerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f00b680..78fe5104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. * [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. +* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes. ### Deprecated * [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`). diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 4da5cb9f..1a6b826e 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Util; +use Closure; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; use Symfony\Component\Console\Helper\DebugFormatterHelper; use Symfony\Component\Console\Helper\ProcessHelper; @@ -18,10 +19,14 @@ use function str_replace; class ProcessRunner implements ProcessRunnerInterface { private ProcessHelper $helper; + private Closure $createProcess; - public function __construct(ProcessHelper $helper) + public function __construct(ProcessHelper $helper, ?callable $createProcess = null) { $this->helper = $helper; + $this->createProcess = $createProcess !== null + ? Closure::fromCallable($createProcess) + : static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL); } public function run(OutputInterface $output, array $cmd): void @@ -32,7 +37,8 @@ class ProcessRunner implements ProcessRunnerInterface /** @var DebugFormatterHelper $formatter */ $formatter = $this->helper->getHelperSet()->get('debug_formatter'); - $process = new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL); + /** @var Process $process */ + $process = ($this->createProcess)($cmd); if ($output->isVeryVerbose()) { $output->write( diff --git a/module/CLI/test/Util/ProcessRunnerTest.php b/module/CLI/test/Util/ProcessRunnerTest.php new file mode 100644 index 00000000..05ac5dd7 --- /dev/null +++ b/module/CLI/test/Util/ProcessRunnerTest.php @@ -0,0 +1,106 @@ +helper = $this->prophesize(ProcessHelper::class); + $this->formatter = $this->prophesize(DebugFormatterHelper::class); + $helperSet = $this->prophesize(HelperSet::class); + $helperSet->get('debug_formatter')->willReturn($this->formatter->reveal()); + $this->helper->getHelperSet()->willReturn($helperSet->reveal()); + $this->process = $this->prophesize(Process::class); + + $this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal()); + $this->output = $this->prophesize(OutputInterface::class); + } + + /** @test */ + public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void + { + $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false); + $isDebug = $this->output->isDebug()->willReturn(false); + $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); + + $this->runner->run($this->output->reveal(), []); + + $isVeryVerbose->shouldHaveBeenCalledTimes(2); + $isDebug->shouldHaveBeenCalledOnce(); + $mustRun->shouldHaveBeenCalledOnce(); + $this->process->isSuccessful()->shouldNotHaveBeenCalled(); + $this->process->getCommandLine()->shouldNotHaveBeenCalled(); + $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function someMessagesAreWrittenWhenOutputIsVerbose(): void + { + $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true); + $isDebug = $this->output->isDebug()->willReturn(false); + $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); + $isSuccessful = $this->process->isSuccessful()->willReturn(true); + $getCommandLine = $this->process->getCommandLine()->willReturn('true'); + $start = $this->formatter->start(Argument::cetera())->willReturn(''); + $stop = $this->formatter->stop(Argument::cetera())->willReturn(''); + + $this->runner->run($this->output->reveal(), []); + + $isVeryVerbose->shouldHaveBeenCalledTimes(2); + $isDebug->shouldHaveBeenCalledOnce(); + $mustRun->shouldHaveBeenCalledOnce(); + $this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2); + $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled(); + $isSuccessful->shouldHaveBeenCalledTimes(2); + $getCommandLine->shouldHaveBeenCalledOnce(); + $start->shouldHaveBeenCalledOnce(); + $stop->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function wrapsCallbackWhenOutputIsDebug(): void + { + $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false); + $isDebug = $this->output->isDebug()->willReturn(true); + $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); + $wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void { + }); + + $this->runner->run($this->output->reveal(), []); + + $isVeryVerbose->shouldHaveBeenCalledTimes(2); + $isDebug->shouldHaveBeenCalledOnce(); + $mustRun->shouldHaveBeenCalledOnce(); + $wrapCallback->shouldHaveBeenCalledOnce(); + $this->process->isSuccessful()->shouldNotHaveBeenCalled(); + $this->process->getCommandLine()->shouldNotHaveBeenCalled(); + $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled(); + } +} From c766cfad895edbfd6cfed24cb666c3e98b22759e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 12 Feb 2021 23:40:29 +0100 Subject: [PATCH 102/115] Updated to shlink-common 3.5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d03790a8..7dde5dcc 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-main#2cf5e45 as 3.5", + "shlinkio/shlink-common": "^3.5", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-importer": "^2.2", From 9a951589dcc2e2e605096c8057148807ee7cfa5d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 09:38:34 +0100 Subject: [PATCH 103/115] Updated year in license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 547f267a..2a381d83 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2020 Alejandro Celaya +Copyright (c) 2016-2021 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From a2030b6c273160937b2ec05f781bce1386b72164 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 11:39:51 +0100 Subject: [PATCH 104/115] Updated to shlink-event-dispatcher 2.1 --- composer.json | 2 +- docker-compose.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 7dde5dcc..518d31bd 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "ramsey/uuid": "^3.9", "shlinkio/shlink-common": "^3.5", "shlinkio/shlink-config": "^1.0", - "shlinkio/shlink-event-dispatcher": "^2.0", + "shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-importer": "^2.2", "shlinkio/shlink-installer": "dev-develop#c489d3f as 5.4", "shlinkio/shlink-ip-geolocation": "^1.5", diff --git a/docker-compose.yml b/docker-compose.yml index ba4558e4..ab7baf1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: shlink_nginx: container_name: shlink_nginx - image: nginx:1.17.10-alpine + image: nginx:1.19.6-alpine ports: - "8000:80" volumes: @@ -34,7 +34,7 @@ services: shlink_swoole_proxy: container_name: shlink_swoole_proxy - image: nginx:1.17.10-alpine + image: nginx:1.19.6-alpine ports: - "8002:80" volumes: @@ -120,7 +120,7 @@ services: shlink_mercure_proxy: container_name: shlink_mercure_proxy - image: nginx:1.17.10-alpine + image: nginx:1.19.6-alpine ports: - "8001:80" volumes: From ede7551856c77a40bb3e3b98863ec5618b4de3c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 12:56:41 +0100 Subject: [PATCH 105/115] Updated build script so that it allows building a dist file for non-swoole envs --- build.sh | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/build.sh b/build.sh index 16610a8b..c6abbc8a 100755 --- a/build.sh +++ b/build.sh @@ -1,35 +1,45 @@ #!/usr/bin/env bash set -e -if [[ "$#" -ne 1 ]]; then +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then echo "Usage:" >&2 - echo " $0 {version}" >&2 + echo " $0 {version} [--no-swoole]" >&2 exit 1 fi version=$1 -builtcontent="./build/shlink_${version}_dist" +noSwoole=$2 +phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') +[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_swoole" +distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist" +builtContent="./build/${distId}" projectdir=$(pwd) [[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer' # Copy project content to temp dir echo 'Copying project files...' -rm -rf "${builtcontent}" -mkdir -p "${builtcontent}" -rsync -av * "${builtcontent}" \ +rm -rf "${builtContent}" +mkdir -p "${builtContent}" +rsync -av * "${builtContent}" \ --exclude=*docker* \ --exclude=Dockerfile \ --include=.htaccess \ --exclude-from=./.dockerignore -cd "${builtcontent}" +cd "${builtContent}" # Install dependencies echo "Installing dependencies with $composerBin..." +composerFlags="--optimize-autoloader --no-progress --no-interaction" ${composerBin} self-update -${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction +${composerBin} install --no-dev --prefer-dist $composerFlags -# Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0) -cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin" +if [[ $noSwoole ]]; then + # If generating a dist not for swoole, uninstall mezzio-swoole + ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags +else + # Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0) + cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin" +fi # Delete development files echo 'Deleting dev files...' @@ -41,9 +51,9 @@ sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php # Compressing file echo 'Compressing files...' cd "${projectdir}"/build -rm -f ./shlink_${version}_dist.zip -zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist +rm -f ./${distId}.zip +zip -ry ./${distId}.zip ./${distId} cd "${projectdir}" -rm -rf "${builtcontent}" +rm -rf "${builtContent}" echo 'Done!' From d9af0a5547a7c549bd9eb0beb3498ef13410432f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 13:29:38 +0100 Subject: [PATCH 106/115] Improved publish-release workflow to generate files for all supported PHP versions and with/without swoole --- .github/workflows/publish-release.yml | 36 +++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3047b4ee..bddaca9a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -7,18 +7,36 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + swoole: ['yes', 'no'] steps: - name: Checkout code uses: actions/checkout@v2 - - name: Use PHP 7.4 + - name: Use PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' # Publish release with lowest supported PHP version + php-version: ${{ matrix.php-version }} tools: composer extensions: swoole-4.6.3 - - name: Generate release assets + - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} + - if: ${{ matrix.swoole == 'no' }} + run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole + - uses: actions/upload-artifact@v2 + with: + name: dist-files + path: build + + publish: + needs: ['build'] + runs-on: ubuntu-20.04 + steps: + - uses: actions/download-artifact@v2 + with: + path: build - name: Publish release with assets uses: docker://antonyurchenko/git-release:latest env: @@ -27,4 +45,12 @@ jobs: ALLOW_EMPTY_CHANGELOG: "true" with: args: | - build/shlink_*_dist.zip + build/shlink*_dist.zip + + delete-artifacts: + needs: ['publish'] + runs-on: ubuntu-20.04 + steps: + - uses: geekyeggo/delete-artifact@v1 + with: + name: dist-files From e3bf046c308d3ac421d67618125db396c56c88d9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 13:44:52 +0100 Subject: [PATCH 107/115] Documented new system with multiple dist files --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 873f83ec..23f9e652 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ In order to run Shlink, you will need a built version of the project. There are The easiest way to install shlink is by using one of the pre-bundled distributable packages. - Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_x.x.x_dist.zip` file you will find there. + Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole integration. Finally, decompress the file in the location of your choice. @@ -57,9 +57,9 @@ In order to run Shlink, you will need a built version of the project. There are * Clone the project with git (`git clone https://github.com/shlinkio/shlink.git`), or download it by clicking the **Clone or download** green button. * Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder. - * Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file). + * Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line). - After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice. + After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice. > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. From 5cd5fb007191a42e5e0dcb7af3108e1605b984aa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 13:49:53 +0100 Subject: [PATCH 108/115] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fe5104..c487575a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination. * [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8. * [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes. +* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole. + + The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones. ### Deprecated * [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`). From f0249346b029ef560cd088a0e84454c609ab10a3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 14:05:31 +0100 Subject: [PATCH 109/115] Fixed version numbers --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c487575a..398cbc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. -* [#941](https://github.com/shlinkio/shlink/issues/856) Added support to provide a title for every short URL. +* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL. The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in. @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns. * [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code. -* [#675](https://github.com/shlinkio/shlink/issues/1000) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits. +* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits. This behavior is enabled by default, but you can opt out via env vars or config options. From 2ac84ac8c4eb2ec650ec352e391d3260839eb80c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 14:12:38 +0100 Subject: [PATCH 110/115] Ensured generated dist files do not conflict --- .github/workflows/publish-release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bddaca9a..287b952a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -27,7 +27,7 @@ jobs: run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole - uses: actions/upload-artifact@v2 with: - name: dist-files + name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} path: build publish: @@ -50,7 +50,11 @@ jobs: delete-artifacts: needs: ['publish'] runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: [ '7.4', '8.0' ] + swoole: [ 'yes', 'no' ] steps: - uses: geekyeggo/delete-artifact@v1 with: - name: dist-files + name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} From 5c4e34807897a5b8426c45985956c0e07a53dd40 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 14:18:49 +0100 Subject: [PATCH 111/115] Ensured repo si cloned durin publish workflow --- .github/workflows/publish-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 287b952a..53ee8849 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -34,6 +34,8 @@ jobs: needs: ['build'] runs-on: ubuntu-20.04 steps: + - name: Checkout code + uses: actions/checkout@v2 - uses: actions/download-artifact@v2 with: path: build From 25b3de84ecd7a9a553c4aea543774fa3d3d8f540 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 14:33:36 +0100 Subject: [PATCH 112/115] Fixed pattern to resolve release artifacts --- .github/workflows/publish-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 53ee8849..18c174c8 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -47,7 +47,7 @@ jobs: ALLOW_EMPTY_CHANGELOG: "true" with: args: | - build/shlink*_dist.zip + build/*/shlink*_dist.zip delete-artifacts: needs: ['publish'] From 0a2b388f6b120db790e73d781b013faafcc58c53 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 14:57:15 +0100 Subject: [PATCH 113/115] Updated to stable shlink-installer 5.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 518d31bd..bdafbb6a 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-importer": "^2.2", - "shlinkio/shlink-installer": "dev-develop#c489d3f as 5.4", + "shlinkio/shlink-installer": "^5.4", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", From 6b5217ece2a5b05b298109227be121bc084b56f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 15:33:56 +0100 Subject: [PATCH 114/115] Added v2.6.0 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 398cbc3e..d1655e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [2.6.0] - 2021-02-13 ### Added * [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support. * [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL. From 656346bd045c4488f5ed21139503d93e3c198355 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Feb 2021 17:48:03 +0100 Subject: [PATCH 115/115] Ensured mezzio-swoole config provider is dynamically loaded --- config/config.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/config.php b/config/config.php index cf9eb86b..2b562874 100644 --- a/config/config.php +++ b/config/config.php @@ -8,14 +8,16 @@ use Laminas\ConfigAggregator; use Laminas\Diactoros; use Mezzio; use Mezzio\ProblemDetails; +use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider; +use function class_exists; use function Shlinkio\Shlink\Common\env; return (new ConfigAggregator\ConfigAggregator([ Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - Mezzio\Swoole\ConfigProvider::class, + class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]), ProblemDetails\ConfigProvider::class, Diactoros\ConfigProvider::class, Common\ConfigProvider::class,