From af0ed6135e09d19d3ed1b1b5cd6037632a0485a1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:03:06 +0100 Subject: [PATCH 001/100] Updated to latest doctrine versions, solving deprecations --- composer.json | 8 ++++---- .../Core/src/EventDispatcher/LocateShortUrlVisit.php | 2 +- module/Core/src/Service/ShortUrlService.php | 4 +--- module/Core/src/Service/Tag/TagService.php | 4 +--- module/Core/src/Service/VisitsTracker.php | 6 ++---- .../test/EventDispatcher/LocateShortUrlVisitTest.php | 12 ++++++------ module/Core/test/Service/ShortUrlServiceTest.php | 10 +++++----- module/Core/test/Service/Tag/TagServiceTest.php | 12 ++++++------ module/Core/test/Service/VisitsTrackerTest.php | 9 ++++----- phpstan.neon | 1 - 10 files changed, 30 insertions(+), 38 deletions(-) diff --git a/composer.json b/composer.json index 34787bd9..1c3ea632 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,10 @@ "akrabat/ip-address-middleware": "^1.0", "cakephp/chronos": "^1.2", "cocur/slugify": "^3.0", - "doctrine/cache": "^1.6", - "doctrine/dbal": "^2.9", - "doctrine/migrations": "^2.0", - "doctrine/orm": "^2.5", + "doctrine/cache": "^1.9", + "doctrine/dbal": "^2.10", + "doctrine/migrations": "^2.2", + "doctrine/orm": "^2.7", "endroid/qr-code": "^3.6", "firebase/php-jwt": "^4.0", "geoip2/geoip2": "^2.9", diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php index 65026af0..1facba39 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php @@ -82,6 +82,6 @@ class LocateShortUrlVisit } $visit->locate(new VisitLocation($location)); - $this->em->flush($visit); + $this->em->flush(); } } diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 1f3ea73a..02977186 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -64,9 +64,7 @@ class ShortUrlService implements ShortUrlServiceInterface $shortUrl = $this->findByShortCode($this->em, $shortCode); $shortUrl->updateMeta($shortUrlMeta); - /** @var ORM\EntityManager $em */ - $em = $this->em; - $em->flush($shortUrl); + $this->em->flush(); return $shortUrl; } diff --git a/module/Core/src/Service/Tag/TagService.php b/module/Core/src/Service/Tag/TagService.php index fd32efe5..d5ac562e 100644 --- a/module/Core/src/Service/Tag/TagService.php +++ b/module/Core/src/Service/Tag/TagService.php @@ -77,9 +77,7 @@ class TagService implements TagServiceInterface $tag->rename($newName); - /** @var ORM\EntityManager $em */ - $em = $this->em; - $em->flush($tag); + $this->em->flush(); return $tag; } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index ebfdf778..12af69ce 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -43,10 +43,8 @@ class VisitsTracker implements VisitsTrackerInterface $visit = new Visit($shortUrl, $visitor); - /** @var ORM\EntityManager $em */ - $em = $this->em; - $em->persist($visit); - $em->flush($visit); + $this->em->persist($visit); + $this->em->flush(); $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId())); } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 14318749..0b557dd8 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -61,7 +61,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); $findVisit->shouldHaveBeenCalledOnce(); - $this->em->flush(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->flush()->shouldNotHaveBeenCalled(); $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled(); $logWarning->shouldHaveBeenCalled(); } @@ -86,7 +86,7 @@ class LocateShortUrlVisitTest extends TestCase $findVisit->shouldHaveBeenCalledOnce(); $resolveLocation->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalled(); - $this->em->flush(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->flush()->shouldNotHaveBeenCalled(); } /** @@ -97,7 +97,7 @@ class LocateShortUrlVisitTest extends TestCase { $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush($visit)->will(function () { + $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any()); @@ -128,7 +128,7 @@ class LocateShortUrlVisitTest extends TestCase $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush($visit)->will(function () { + $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); @@ -151,7 +151,7 @@ class LocateShortUrlVisitTest extends TestCase $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush($visit)->will(function () { + $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); @@ -179,7 +179,7 @@ class LocateShortUrlVisitTest extends TestCase $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush($visit)->will(function () { + $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 9fd19394..07628f7c 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -35,7 +35,7 @@ class ShortUrlServiceTest extends TestCase } /** @test */ - public function listedUrlsAreReturnedFromEntityManager() + public function listedUrlsAreReturnedFromEntityManager(): void { $list = [ new ShortUrl(''), @@ -54,7 +54,7 @@ class ShortUrlServiceTest extends TestCase } /** @test */ - public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode() + public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode(): void { $shortCode = 'abc123'; $repo = $this->prophesize(ShortUrlRepository::class); @@ -67,7 +67,7 @@ class ShortUrlServiceTest extends TestCase } /** @test */ - public function providedTagsAreGetFromRepoAndSetToTheShortUrl() + public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void { $shortUrl = $this->prophesize(ShortUrl::class); $shortUrl->setTags(Argument::any())->shouldBeCalledOnce(); @@ -86,14 +86,14 @@ class ShortUrlServiceTest extends TestCase } /** @test */ - public function updateMetadataByShortCodeUpdatesProvidedData() + public function updateMetadataByShortCodeUpdatesProvidedData(): void { $shortUrl = new ShortUrl(''); $repo = $this->prophesize(ShortUrlRepository::class); $findShortUrl = $repo->findOneBy(['shortCode' => 'abc123'])->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $flush = $this->em->flush($shortUrl)->willReturn(null); + $flush = $this->em->flush()->willReturn(null); $result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::createFromParams( Chronos::parse('2017-01-01 00:00:00')->toAtomString(), diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 7670eafd..0dd63ff5 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -28,7 +28,7 @@ class TagServiceTest extends TestCase } /** @test */ - public function listTagsDelegatesOnRepository() + public function listTagsDelegatesOnRepository(): void { $expected = [new Tag('foo'), new Tag('bar')]; @@ -44,7 +44,7 @@ class TagServiceTest extends TestCase } /** @test */ - public function deleteTagsDelegatesOnRepository() + public function deleteTagsDelegatesOnRepository(): void { $repo = $this->prophesize(TagRepository::class); $delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4); @@ -57,7 +57,7 @@ class TagServiceTest extends TestCase } /** @test */ - public function createTagsPersistsEntities() + public function createTagsPersistsEntities(): void { $repo = $this->prophesize(TagRepository::class); $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); @@ -75,7 +75,7 @@ class TagServiceTest extends TestCase } /** @test */ - public function renameInvalidTagThrowsException() + public function renameInvalidTagThrowsException(): void { $repo = $this->prophesize(TagRepository::class); $find = $repo->findOneBy(Argument::cetera())->willReturn(null); @@ -89,14 +89,14 @@ class TagServiceTest extends TestCase } /** @test */ - public function renameValidTagChangesItsName() + public function renameValidTagChangesItsName(): void { $expected = new Tag('foo'); $repo = $this->prophesize(TagRepository::class); $find = $repo->findOneBy(Argument::cetera())->willReturn($expected); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); - $flush = $this->em->flush($expected)->willReturn(null); + $flush = $this->em->flush()->willReturn(null); $tag = $this->service->renameTag('foo', 'bar'); diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index f137fd54..a59429fb 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -46,11 +46,11 @@ class VisitsTrackerTest extends TestCase $repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl('')); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); - $this->em->persist(Argument::any())->shouldBeCalledOnce(); - $this->em->flush(Argument::that(function (Visit $visit) { + $this->em->persist(Argument::that(function (Visit $visit) { $visit->setId('1'); return $visit; }))->shouldBeCalledOnce(); + $this->em->flush()->shouldBeCalledOnce(); $this->visitsTracker->track($shortCode, Visitor::emptyInstance()); @@ -69,11 +69,10 @@ class VisitsTrackerTest extends TestCase /** @var Visit $visit */ $visit = $args[0]; Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr()); - })->shouldBeCalledOnce(); - $this->em->flush(Argument::that(function (Visit $visit) { $visit->setId('1'); return $visit; - }))->shouldBeCalledOnce(); + })->shouldBeCalledOnce(); + $this->em->flush()->shouldBeCalledOnce(); $this->visitsTracker->track($shortCode, new Visitor('', '', '4.3.2.1')); diff --git a/phpstan.neon b/phpstan.neon index 8b5db673..7df6d88f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,6 @@ parameters: ignoreErrors: - '#is not subtype of Throwable#' - - '#ObjectManager::flush()#' - '#Undefined variable: \$metadata#' - '#AbstractQuery::setParameters()#' - '#mustRun()#' From 84c4631124d9cb542c502a42315fc88af6a81f01 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:18:21 +0100 Subject: [PATCH 002/100] Deleted specific factory by replacing it by ConfigAbstractFactory --- module/Rest/config/dependencies.config.php | 4 +- .../Rest/src/Action/HealthActionFactory.php | 20 --------- module/Rest/src/Authentication/JWTService.php | 1 + .../test/Action/HealthActionFactoryTest.php | 44 ------------------- 4 files changed, 4 insertions(+), 65 deletions(-) delete mode 100644 module/Rest/src/Action/HealthActionFactory.php delete mode 100644 module/Rest/test/Action/HealthActionFactoryTest.php diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 54f380b3..5ef1ecba 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; +use Doctrine\DBAL\Connection; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service; @@ -20,7 +21,7 @@ return [ ApiKeyService::class => ConfigAbstractFactory::class, Action\AuthenticateAction::class => ConfigAbstractFactory::class, - Action\HealthAction::class => Action\HealthActionFactory::class, + Action\HealthAction::class => ConfigAbstractFactory::class, Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, @@ -48,6 +49,7 @@ return [ ApiKeyService::class => ['em'], Action\AuthenticateAction::class => [ApiKeyService::class, Authentication\JWTService::class, 'Logger_Shlink'], + Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'], Action\ShortUrl\CreateShortUrlAction::class => [ Service\UrlShortener::class, 'config.url_shortener.domain', diff --git a/module/Rest/src/Action/HealthActionFactory.php b/module/Rest/src/Action/HealthActionFactory.php deleted file mode 100644 index 269a8f4d..00000000 --- a/module/Rest/src/Action/HealthActionFactory.php +++ /dev/null @@ -1,20 +0,0 @@ -get(EntityManager::class); - $options = $container->get(AppOptions::class); - $logger = $container->get('Logger_Shlink'); - return new HealthAction($em->getConnection(), $options, $logger); - } -} diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php index 841527e4..77ec6728 100644 --- a/module/Rest/src/Authentication/JWTService.php +++ b/module/Rest/src/Authentication/JWTService.php @@ -12,6 +12,7 @@ use UnexpectedValueException; use function time; +/** @deprecated */ class JWTService implements JWTServiceInterface { /** @var AppOptions */ diff --git a/module/Rest/test/Action/HealthActionFactoryTest.php b/module/Rest/test/Action/HealthActionFactoryTest.php deleted file mode 100644 index 3ecb15c1..00000000 --- a/module/Rest/test/Action/HealthActionFactoryTest.php +++ /dev/null @@ -1,44 +0,0 @@ -factory = new Action\HealthActionFactory(); - } - - /** @test */ - public function serviceIsCreatedExtractingConnectionFromEntityManager() - { - $em = $this->prophesize(EntityManager::class); - $conn = $this->prophesize(Connection::class); - - $getConnection = $em->getConnection()->willReturn($conn->reveal()); - - $sm = new ServiceManager(['services' => [ - 'Logger_Shlink' => $this->prophesize(LoggerInterface::class)->reveal(), - AppOptions::class => $this->prophesize(AppOptions::class)->reveal(), - EntityManager::class => $em->reveal(), - ]]); - - $instance = ($this->factory)($sm, ''); - - $this->assertInstanceOf(Action\HealthAction::class, $instance); - $getConnection->shouldHaveBeenCalledOnce(); - } -} From 98b6dba05d34cf2cf8a88db55b7f5a1044b83f0d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:21:02 +0100 Subject: [PATCH 003/100] Removed generic error handling from action that will usually be handled by ErrorHandler middleware --- .../ShortUrl/AbstractCreateShortUrlAction.php | 7 ------- .../Action/ShortUrl/CreateShortUrlActionTest.php | 16 ---------------- 2 files changed, 23 deletions(-) diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index e55f564e..f3eaee19 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Util\RestUtils; -use Throwable; use Zend\Diactoros\Response\JsonResponse; use function sprintf; @@ -74,12 +73,6 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction 'error' => RestUtils::getRestErrorCodeFromException($e), 'message' => sprintf('Provided slug %s is already in use. Try with a different one.', $customSlug), ], self::STATUS_BAD_REQUEST); - } catch (Throwable $e) { - $this->logger->error('Unexpected error creating short url. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occurred', - ], self::STATUS_INTERNAL_SERVER_ERROR); } } diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index cea67f0f..732cbc25 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Exception; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; @@ -124,19 +123,4 @@ class CreateShortUrlActionTest extends TestCase $this->assertEquals(400, $response->getStatusCode()); $this->assertStringContainsString(RestUtils::INVALID_SLUG_ERROR, (string) $response->getBody()); } - - /** @test */ - public function aGenericExceptionWillReturnError(): void - { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) - ->willThrow(Exception::class) - ->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withParsedBody([ - 'longUrl' => 'http://www.domain.com/foo/bar', - ]); - $response = $this->action->handle($request); - $this->assertEquals(500, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0); - } } From ba6e8c4092e8b425930a7d6ad334b7ed3ec018da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:31:18 +0100 Subject: [PATCH 004/100] Created API tests for errors when editing a short URL --- bin/test/run-api-tests.sh | 2 +- module/Rest/src/Util/RestUtils.php | 4 ++- .../Action/EditShortUrlActionTest.php | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 module/Rest/test-api/Action/EditShortUrlActionTest.php diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 75a51387..cbc10a1a 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -9,7 +9,7 @@ echo 'Starting server...' vendor/bin/zend-expressive-swoole start -d sleep 2 -vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always +vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* testsExitCode=$? vendor/bin/zend-expressive-swoole stop diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index d4ad46dc..3b56fef0 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Exception as Core; use Shlinkio\Shlink\Rest\Exception as Rest; use Throwable; +/** @deprecated */ class RestUtils { public const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; @@ -24,7 +25,8 @@ class RestUtils public const NOT_FOUND_ERROR = 'NOT_FOUND'; public const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; - public static function getRestErrorCodeFromException(Throwable $e) + /** @deprecated */ + public static function getRestErrorCodeFromException(Throwable $e): string { switch (true) { case $e instanceof Core\InvalidShortCodeException: diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php new file mode 100644 index 00000000..65f2fd1d --- /dev/null +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -0,0 +1,34 @@ +callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => []]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + } + + /** @test */ + public function providingInvalidDataReturnsBadRequest(): void + { + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => [ + 'maxVisits' => 'not_a_number', + ]]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + } +} From 909631896836e4ad0e81da0d68a7ecded1314d3e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:38:19 +0100 Subject: [PATCH 005/100] Created API tests for errors when deleting short URLs --- module/Rest/src/Util/RestUtils.php | 1 - .../Action/DeleteShortUrlActionTest.php | 37 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 module/Rest/test-api/Action/DeleteShortUrlActionTest.php diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 3b56fef0..60aade54 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -9,7 +9,6 @@ use Shlinkio\Shlink\Core\Exception as Core; use Shlinkio\Shlink\Rest\Exception as Rest; use Throwable; -/** @deprecated */ class RestUtils { public const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php new file mode 100644 index 00000000..60631565 --- /dev/null +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -0,0 +1,37 @@ +callApiWithKey(self::METHOD_DELETE, '/short-urls/invalid'); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + } + + /** @test */ + public function badRequestIsReturnedWhenTryingToDeleteUrlWithTooManyVisits(): void + { + // Generate visits first + for ($i = 0; $i < 20; $i++) { + $this->assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode()); + } + + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123'); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_DELETION_ERROR, $error); + } +} From d044e1a5b771574933d974284ea91289e0fbad62 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:44:03 +0100 Subject: [PATCH 006/100] Created API tests for errors when resolving short URLs --- module/Core/src/Service/UrlShortener.php | 2 -- .../src/Service/UrlShortenerInterface.php | 2 -- .../Action/ShortUrl/ResolveShortUrlAction.php | 19 ++----------- .../Action/DeleteShortUrlActionTest.php | 1 - .../Action/ResolveShortUrlActionTest.php | 21 ++++++++++++++ .../ShortUrl/ResolveShortUrlActionTest.php | 28 ------------------- 6 files changed, 24 insertions(+), 49 deletions(-) create mode 100644 module/Rest/test-api/Action/ResolveShortUrlActionTest.php diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 6b04d63a..7d71ca7a 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -9,7 +9,6 @@ use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -130,7 +129,6 @@ class UrlShortener implements UrlShortenerInterface } /** - * @throws InvalidShortCodeException * @throws EntityDoesNotExistException */ public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 74d626fc..9d6b291b 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Service; use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\RuntimeException; @@ -24,7 +23,6 @@ interface UrlShortenerInterface public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl; /** - * @throws InvalidShortCodeException * @throws EntityDoesNotExistException */ public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl; diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 57d3cd62..9599013a 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -4,12 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; -use Exception; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -41,7 +40,7 @@ class ResolveShortUrlAction extends AbstractRestAction /** * @param Request $request * @return Response - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function handle(Request $request): Response { @@ -52,24 +51,12 @@ class ResolveShortUrlAction extends AbstractRestAction try { $url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); return new JsonResponse($transformer->transform($url)); - } catch (InvalidShortCodeException $e) { - $this->logger->warning('Provided short code with invalid format. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided short code "%s" has an invalid format', $shortCode), - ], self::STATUS_BAD_REQUEST); } catch (EntityDoesNotExistException $e) { $this->logger->warning('Provided short code couldn\'t be found. {e}', ['e' => $e]); return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, // FIXME Not correct. Use correct value on "type" 'message' => sprintf('No URL found for short code "%s"', $shortCode), ], self::STATUS_NOT_FOUND); - } catch (Exception $e) { - $this->logger->error('Unexpected error while resolving the URL behind a short code. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occurred', - ], self::STATUS_INTERNAL_SERVER_ERROR); } } } diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index 60631565..c90b7818 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; -use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php new file mode 100644 index 00000000..117ae5a9 --- /dev/null +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -0,0 +1,21 @@ +callApiWithKey(self::METHOD_GET, '/short-urls/invalid'); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + } +} diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index e0c23d31..aa852de5 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -4,12 +4,10 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Exception; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; use Shlinkio\Shlink\Rest\Util\RestUtils; @@ -56,30 +54,4 @@ class ResolveShortUrlActionTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); $this->assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); } - - /** @test */ - public function invalidShortCodeExceptionReturnsError(): void - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(InvalidShortCodeException::class) - ->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $response = $this->action->handle($request); - $this->assertEquals(400, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_SHORTCODE_ERROR) > 0); - } - - /** @test */ - public function unexpectedExceptionWillReturnError(): void - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(Exception::class) - ->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $response = $this->action->handle($request); - $this->assertEquals(500, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0); - } } From 34e60ec5b80de829d04e0ee9c2f0a00b84f8658f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2019 20:58:16 +0100 Subject: [PATCH 007/100] Created API tests for errors when getting short URL visits --- module/Core/src/Service/VisitsTracker.php | 8 +++---- .../src/Service/VisitsTrackerInterface.php | 4 ++-- .../Rest/src/Action/Visit/GetVisitsAction.php | 6 +++--- .../test-api/Action/GetVisitsActionTest.php | 21 +++++++++++++++++++ .../test/Action/Visit/GetVisitsActionTest.php | 10 ++++----- 5 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 module/Rest/test-api/Action/GetVisitsActionTest.php diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index 12af69ce..836c16a9 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -9,15 +9,13 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Zend\Paginator\Paginator; -use function sprintf; - class VisitsTracker implements VisitsTrackerInterface { /** @var ORM\EntityManagerInterface */ @@ -53,14 +51,14 @@ class VisitsTracker implements VisitsTrackerInterface * Returns the visits on certain short code * * @return Visit[]|Paginator - * @throws InvalidArgumentException + * @throws InvalidShortCodeException */ public function info(string $shortCode, VisitsParams $params): Paginator { /** @var ORM\EntityRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); if ($repo->count(['shortCode' => $shortCode]) < 1) { - throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode)); + throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); } /** @var VisitRepository $repo */ diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 03af8299..d3934992 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Zend\Paginator\Paginator; @@ -21,7 +21,7 @@ interface VisitsTrackerInterface * Returns the visits on certain short code * * @return Visit[]|Paginator - * @throws InvalidArgumentException + * @throws InvalidShortCodeException */ public function info(string $shortCode, VisitsParams $params): Paginator; } diff --git a/module/Rest/src/Action/Visit/GetVisitsAction.php b/module/Rest/src/Action/Visit/GetVisitsAction.php index 222a76b8..68912be2 100644 --- a/module/Rest/src/Action/Visit/GetVisitsAction.php +++ b/module/Rest/src/Action/Visit/GetVisitsAction.php @@ -7,8 +7,8 @@ namespace Shlinkio\Shlink\Rest\Action\Visit; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -48,10 +48,10 @@ class GetVisitsAction extends AbstractRestAction return new JsonResponse([ 'visits' => $this->serializePaginator($visits), ]); - } catch (InvalidArgumentException $e) { + } catch (InvalidShortCodeException $e) { $this->logger->warning('Provided nonexistent short code {e}', ['e' => $e]); return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, // FIXME Wrong code. Use correct one in "type" 'message' => sprintf('Provided short code %s does not exist', $shortCode), ], self::STATUS_NOT_FOUND); } diff --git a/module/Rest/test-api/Action/GetVisitsActionTest.php b/module/Rest/test-api/Action/GetVisitsActionTest.php new file mode 100644 index 00000000..150c3066 --- /dev/null +++ b/module/Rest/test-api/Action/GetVisitsActionTest.php @@ -0,0 +1,21 @@ +callApiWithKey(self::METHOD_GET, '/short-urls/invalid/visits'); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + } +} diff --git a/module/Rest/test/Action/Visit/GetVisitsActionTest.php b/module/Rest/test/Action/Visit/GetVisitsActionTest.php index 6a4b533c..789469ca 100644 --- a/module/Rest/test/Action/Visit/GetVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GetVisitsActionTest.php @@ -8,8 +8,8 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction; @@ -31,7 +31,7 @@ class GetVisitsActionTest extends TestCase } /** @test */ - public function providingCorrectShortCodeReturnsVisits() + public function providingCorrectShortCodeReturnsVisits(): void { $shortCode = 'abc123'; $this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willReturn( @@ -43,11 +43,11 @@ class GetVisitsActionTest extends TestCase } /** @test */ - public function providingInvalidShortCodeReturnsError() + public function providingInvalidShortCodeReturnsError(): void { $shortCode = 'abc123'; $this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willThrow( - InvalidArgumentException::class + InvalidShortCodeException::class )->shouldBeCalledOnce(); $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); @@ -55,7 +55,7 @@ class GetVisitsActionTest extends TestCase } /** @test */ - public function paramsAreReadFromQuery() + public function paramsAreReadFromQuery(): void { $shortCode = 'abc123'; $this->visitsTracker->info($shortCode, new VisitsParams( From 8607d58e186db5a2dd1edb49bcbf9968e9b3ca2d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Nov 2019 18:49:55 +0100 Subject: [PATCH 008/100] Created API tests for errors when editting short URL tags --- .../Action/ShortUrl/ListShortUrlsAction.php | 23 ++++--------- .../Action/EditShortUrlActionTagsTest.php | 34 +++++++++++++++++++ .../ShortUrl/ListShortUrlsActionTest.php | 23 ------------- 3 files changed, 41 insertions(+), 39 deletions(-) create mode 100644 module/Rest/test-api/Action/EditShortUrlActionTagsTest.php diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 8c4211c8..157fbe06 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; -use Exception; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; @@ -12,7 +12,6 @@ use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; class ListShortUrlsAction extends AbstractRestAction @@ -40,23 +39,15 @@ class ListShortUrlsAction extends AbstractRestAction /** * @param Request $request * @return Response - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function handle(Request $request): Response { - try { - $params = $this->queryToListParams($request->getQueryParams()); - $shortUrls = $this->shortUrlService->listShortUrls(...$params); - return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( - $this->domainConfig - ))]); - } catch (Exception $e) { - $this->logger->error('Unexpected error while listing short URLs. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occurred', - ], self::STATUS_INTERNAL_SERVER_ERROR); - } + $params = $this->queryToListParams($request->getQueryParams()); + $shortUrls = $this->shortUrlService->listShortUrls(...$params); + return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( + $this->domainConfig + ))]); } /** diff --git a/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php b/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php new file mode 100644 index 00000000..d033fcee --- /dev/null +++ b/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php @@ -0,0 +1,34 @@ +callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => []]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + } + + /** @test */ + public function providingInvalidShortCodeReturnsBadRequest(): void + { + $resp = $this->callApiWithKey(self::METHOD_PUT, '/short-urls/invalid/tags', [RequestOptions::JSON => [ + 'tags' => ['foo', 'bar'], + ]]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + } +} diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 7bb2d623..4197aba8 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Exception; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; @@ -79,26 +78,4 @@ class ListShortUrlsActionTest extends TestCase 'tags' => $tags = ['one', 'two'], ], 2, null, $tags, $orderBy]; } - - /** @test */ - public function anExceptionReturnsErrorResponse(): void - { - $page = 3; - $e = new Exception(); - - $this->service->listShortUrls($page, null, [], null)->willThrow($e) - ->shouldBeCalledOnce(); - $logError = $this->logger->error( - 'Unexpected error while listing short URLs. {e}', - ['e' => $e] - )->will(function () { - }); - - $response = $this->action->handle((new ServerRequest())->withQueryParams([ - 'page' => $page, - ])); - - $this->assertEquals(500, $response->getStatusCode()); - $logError->shouldHaveBeenCalledOnce(); - } } From b3b67b051dc07489424f58e830b4863bc3be93d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Nov 2019 19:03:34 +0100 Subject: [PATCH 009/100] Created API tests for errors when updating tags --- module/Rest/config/dependencies.config.php | 2 +- .../test-api/Action/UpdateTagActionTest.php | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 module/Rest/test-api/Action/UpdateTagActionTest.php diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 5ef1ecba..98a26bc1 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -27,9 +27,9 @@ return [ Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class, - Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class, + Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class, Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, diff --git a/module/Rest/test-api/Action/UpdateTagActionTest.php b/module/Rest/test-api/Action/UpdateTagActionTest.php new file mode 100644 index 00000000..0f7c0400 --- /dev/null +++ b/module/Rest/test-api/Action/UpdateTagActionTest.php @@ -0,0 +1,45 @@ +callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => $body]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + } + + public function provideInvalidBody(): iterable + { + yield [[]]; + yield [['oldName' => 'foo']]; + yield [['newName' => 'foo']]; + } + + /** @test */ + public function tryingToRenameInvalidTagReturnsNotFound(): void + { + $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [ + 'oldName' => 'invalid_tag', + 'newName' => 'foo', + ]]); + ['error' => $error] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(RestUtils::NOT_FOUND_ERROR, $error); + } +} From ad592a563c74ec14105b85da49381a82a1a6cd33 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Nov 2019 19:22:04 +0100 Subject: [PATCH 010/100] Updated testing utils library --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1c3ea632..f1bc1631 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ "phpunit/phpunit": "^8.3", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.0.0", - "shlinkio/shlink-test-utils": "^1.0", + "shlinkio/shlink-test-utils": "^1.1", "symfony/dotenv": "^4.3", "symfony/var-dumper": "^4.3", "zendframework/zend-component-installer": "^2.1", From 6ddb60d04750643dfd397a719bf4cf4b82e7d24b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 21 Nov 2019 20:05:06 +0100 Subject: [PATCH 011/100] Improved ValidationException to avoid polluting the message with invalid data but keeping it on the string representation --- .../src/Exception/ValidationException.php | 41 +++++++++++-------- .../Exception/ValidationExceptionTest.php | 8 ++-- .../ShortUrl/AbstractCreateShortUrlAction.php | 6 +-- .../Action/ShortUrl/CreateShortUrlAction.php | 30 ++++++-------- .../SingleStepCreateShortUrlAction.php | 13 +++--- .../SingleStepCreateShortUrlActionTest.php | 10 ++--- 6 files changed, 55 insertions(+), 53 deletions(-) diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 1b767594..70dfe0d0 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -34,24 +34,34 @@ class ValidationException extends RuntimeException return static::fromArray($inputFilter->getMessages(), $prev); } - private static function fromArray(array $invalidData, ?Throwable $prev = null): self + public static function fromArray(array $invalidData, ?Throwable $prev = null): self { - return new self( - sprintf( - 'Provided data is not valid. These are the messages:%s%s%s', - PHP_EOL, - self::formMessagesToString($invalidData), - PHP_EOL - ), - $invalidData, - -1, - $prev + return new self('Provided data is not valid', $invalidData, -1, $prev); + } + + public function getInvalidElements(): array + { + return $this->invalidElements; + } + + public function __toString(): string + { + return sprintf( + '%s %s in %s:%s%s%sStack trace:%s%s', + __CLASS__, + $this->getMessage(), + $this->getFile(), + $this->getLine(), + $this->invalidElementsToString(), + PHP_EOL, + PHP_EOL, + $this->getTraceAsString() ); } - private static function formMessagesToString(array $messages = []): string + private function invalidElementsToString(): string { - return reduce_left($messages, function ($messageSet, $name, $_, string $acc) { + return reduce_left($this->invalidElements, function ($messageSet, string $name, $_, string $acc) { return $acc . sprintf( "\n '%s' => %s", $name, @@ -59,9 +69,4 @@ class ValidationException extends RuntimeException ); }, ''); } - - public function getInvalidElements(): array - { - return $this->invalidElements; - } } diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 5cab422c..bd7855e2 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -55,12 +55,9 @@ class ValidationExceptionTest extends TestCase 'something' => ['baz', 'foo'], ]; $barValue = print_r(['baz', 'foo'], true); - $expectedMessage = << bar 'something' => {$barValue} - EOT; $inputFilter = $this->prophesize(InputFilterInterface::class); @@ -69,9 +66,10 @@ EOT; $e = ValidationException::fromInputFilter($inputFilter->reveal()); $this->assertEquals($invalidData, $e->getInvalidElements()); - $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals('Provided data is not valid', $e->getMessage()); $this->assertEquals(-1, $e->getCode()); $this->assertEquals($prev, $e->getPrevious()); + $this->assertStringContainsString($expectedStringRepresentation, (string) $e); $getMessages->shouldHaveBeenCalledOnce(); } diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index f3eaee19..bc0d50ef 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; @@ -44,7 +44,7 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction { try { $shortUrlData = $this->buildShortUrlData($request); - } catch (InvalidArgumentException $e) { + } catch (ValidationException $e) { $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, @@ -79,7 +79,7 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction /** * @param Request $request * @return CreateShortUrlData - * @throws InvalidArgumentException + * @throws ValidationException */ abstract protected function buildShortUrlData(Request $request): CreateShortUrlData; } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 3b0a0b61..9c20de6c 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 Cake\Chronos\Chronos; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -20,30 +19,27 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction /** * @param Request $request * @return CreateShortUrlData - * @throws InvalidArgumentException - * @throws \InvalidArgumentException + * @throws ValidationException */ protected function buildShortUrlData(Request $request): CreateShortUrlData { $postData = (array) $request->getParsedBody(); if (! isset($postData['longUrl'])) { - throw new InvalidArgumentException('A URL was not provided'); + throw ValidationException::fromArray([ + 'longUrl' => 'A URL was not provided', + ]); } - try { - $meta = ShortUrlMeta::createFromParams( - $this->getOptionalDate($postData, 'validSince'), - $this->getOptionalDate($postData, 'validUntil'), - $postData['customSlug'] ?? null, - $postData['maxVisits'] ?? null, - $postData['findIfExists'] ?? null, - $postData['domain'] ?? null - ); + $meta = ShortUrlMeta::createFromParams( + $this->getOptionalDate($postData, 'validSince'), + $this->getOptionalDate($postData, 'validUntil'), + $postData['customSlug'] ?? null, + $postData['maxVisits'] ?? null, + $postData['findIfExists'] ?? null, + $postData['domain'] ?? null + ); - return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta); - } catch (ValidationException $e) { - throw new InvalidArgumentException('Provided meta data is not valid', -1, $e); - } + return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta); } private function getOptionalDate(array $postData, string $fieldName): ?Chronos diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 327df46e..834c6b12 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 Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -33,19 +33,22 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction /** * @param Request $request * @return CreateShortUrlData - * @throws \InvalidArgumentException - * @throws InvalidArgumentException + * @throws ValidationException */ protected function buildShortUrlData(Request $request): CreateShortUrlData { $query = $request->getQueryParams(); if (! $this->apiKeyService->check($query['apiKey'] ?? '')) { - throw new InvalidArgumentException('No API key was provided or it is not valid'); + throw ValidationException::fromArray([ + 'apiKey' => 'No API key was provided or it is not valid', + ]); } if (! isset($query['longUrl'])) { - throw new InvalidArgumentException('A URL was not provided'); + throw ValidationException::fromArray([ + 'longUrl' => 'A URL was not provided', + ]); } return new CreateShortUrlData(new Uri($query['longUrl'])); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index f3452e9a..8bebc6c5 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -42,7 +42,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase } /** @test */ - public function errorResponseIsReturnedIfInvalidApiKeyIsProvided() + public function errorResponseIsReturnedIfInvalidApiKeyIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); $findApiKey = $this->apiKeyService->check('abc123')->willReturn(false); @@ -53,12 +53,12 @@ class SingleStepCreateShortUrlActionTest extends TestCase $this->assertEquals(400, $resp->getStatusCode()); $this->assertEquals('INVALID_ARGUMENT', $payload['error']); - $this->assertEquals('No API key was provided or it is not valid', $payload['message']); + $this->assertEquals('Provided data is not valid', $payload['message']); $findApiKey->shouldHaveBeenCalled(); } /** @test */ - public function errorResponseIsReturnedIfNoUrlIsProvided() + public function errorResponseIsReturnedIfNoUrlIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); @@ -69,12 +69,12 @@ class SingleStepCreateShortUrlActionTest extends TestCase $this->assertEquals(400, $resp->getStatusCode()); $this->assertEquals('INVALID_ARGUMENT', $payload['error']); - $this->assertEquals('A URL was not provided', $payload['message']); + $this->assertEquals('Provided data is not valid', $payload['message']); $findApiKey->shouldHaveBeenCalled(); } /** @test */ - public function properDataIsPassedWhenGeneratingShortCode() + public function properDataIsPassedWhenGeneratingShortCode(): void { $request = (new ServerRequest())->withQueryParams([ 'apiKey' => 'abc123', From a0510d6a691dec3d34e14b11d3ca99ae08cfe3db Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Nov 2019 18:01:38 +0100 Subject: [PATCH 012/100] Removed content-based-error-handler in preparation for the problem details module --- composer.json | 1 - .../autoload/middleware-pipeline.global.php | 13 +++- config/config.php | 2 - module/Rest/config/error-handler.config.php | 21 ------- module/Rest/src/Action/HealthAction.php | 6 +- .../JsonErrorResponseGenerator.php | 44 ------------- module/Rest/test/ConfigProviderTest.php | 3 +- .../JsonErrorResponseGeneratorTest.php | 62 ------------------- 8 files changed, 16 insertions(+), 136 deletions(-) delete mode 100644 module/Rest/config/error-handler.config.php delete mode 100644 module/Rest/src/ErrorHandler/JsonErrorResponseGenerator.php delete mode 100644 module/Rest/test/ErrorHandler/JsonErrorResponseGeneratorTest.php diff --git a/composer.json b/composer.json index f1bc1631..4f4f3041 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ "php": "^7.2", "ext-json": "*", "ext-pdo": "*", - "acelaya/ze-content-based-error-handler": "^3.0", "akrabat/ip-address-middleware": "^1.0", "cakephp/chronos": "^1.2", "cocur/slugify": "^3.0", diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index f9af01e3..9ea5e260 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -10,9 +10,20 @@ use Zend\Stratigility\Middleware\ErrorHandler; return [ 'middleware_pipeline' => [ - 'pre-routing' => [ + 'error-handler' => [ 'middleware' => [ ErrorHandler::class, + ], + 'priority' => 15, + ], +// 'error-handler-rest' => [ +// 'path' => '/rest', +// 'middleware' => [], +// 'priority' => 14, +// ], + + 'pre-routing' => [ + 'middleware' => [ Expressive\Helper\ContentLengthMiddleware::class, Common\Middleware\CloseDbConnectionMiddleware::class, ], diff --git a/config/config.php b/config/config.php index 352a27ca..2c817d1e 100644 --- a/config/config.php +++ b/config/config.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Acelaya\ExpressiveErrorHandler; use Zend\ConfigAggregator; use Zend\Expressive; @@ -16,7 +15,6 @@ return (new ConfigAggregator\ConfigAggregator([ Expressive\Router\FastRouteRouter\ConfigProvider::class, Expressive\Plates\ConfigProvider::class, Expressive\Swoole\ConfigProvider::class, - ExpressiveErrorHandler\ConfigProvider::class, Common\ConfigProvider::class, IpGeolocation\ConfigProvider::class, Core\ConfigProvider::class, diff --git a/module/Rest/config/error-handler.config.php b/module/Rest/config/error-handler.config.php deleted file mode 100644 index c7503b72..00000000 --- a/module/Rest/config/error-handler.config.php +++ /dev/null @@ -1,21 +0,0 @@ - [ - 'plugins' => [ - 'invokables' => [ - 'application/json' => JsonErrorResponseGenerator::class, - ], - 'aliases' => [ - 'application/x-json' => 'application/json', - 'text/json' => 'application/json', - ], - ], - ], - -]; diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index 23090165..ac548fde 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -15,8 +15,8 @@ use Zend\Diactoros\Response\JsonResponse; class HealthAction extends AbstractRestAction { private const HEALTH_CONTENT_TYPE = 'application/health+json'; - private const PASS_STATUS = 'pass'; - private const FAIL_STATUS = 'fail'; + private const STATUS_PASS = 'pass'; + private const STATUS_FAIL = 'fail'; protected const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; @@ -48,7 +48,7 @@ class HealthAction extends AbstractRestAction $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; return new JsonResponse([ - 'status' => $connected ? self::PASS_STATUS : self::FAIL_STATUS, + 'status' => $connected ? self::STATUS_PASS : self::STATUS_FAIL, 'version' => $this->options->getVersion(), 'links' => [ 'about' => 'https://shlink.io', diff --git a/module/Rest/src/ErrorHandler/JsonErrorResponseGenerator.php b/module/Rest/src/ErrorHandler/JsonErrorResponseGenerator.php deleted file mode 100644 index ca9ed12c..00000000 --- a/module/Rest/src/ErrorHandler/JsonErrorResponseGenerator.php +++ /dev/null @@ -1,44 +0,0 @@ -getStatusCode(); - $responsePhrase = $status < 400 ? 'Internal Server Error' : $response->getReasonPhrase(); - $status = $status < 400 ? self::STATUS_INTERNAL_SERVER_ERROR : $status; - - return new JsonResponse([ - 'error' => $this->responsePhraseToCode($responsePhrase), - 'message' => $responsePhrase, - ], $status); - } - - private function responsePhraseToCode(string $responsePhrase): string - { - return strtoupper(str_replace(' ', '_', $responsePhrase)); - } -} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 57959367..3cd574b3 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -18,11 +18,10 @@ class ConfigProviderTest extends TestCase } /** @test */ - public function properConfigIsReturned() + public function properConfigIsReturned(): void { $config = $this->configProvider->__invoke(); - $this->assertArrayHasKey('error_handler', $config); $this->assertArrayHasKey('routes', $config); $this->assertArrayHasKey('dependencies', $config); } diff --git a/module/Rest/test/ErrorHandler/JsonErrorResponseGeneratorTest.php b/module/Rest/test/ErrorHandler/JsonErrorResponseGeneratorTest.php deleted file mode 100644 index d9b99bdd..00000000 --- a/module/Rest/test/ErrorHandler/JsonErrorResponseGeneratorTest.php +++ /dev/null @@ -1,62 +0,0 @@ -errorHandler = new JsonErrorResponseGenerator(); - } - - /** @test */ - public function noErrorStatusReturnsInternalServerError(): void - { - /** @var Response\JsonResponse $response */ - $response = $this->errorHandler->__invoke(null, new ServerRequest(), new Response()); - $payload = $response->getPayload(); - - $this->assertInstanceOf(Response\JsonResponse::class, $response); - $this->assertEquals(500, $response->getStatusCode()); - $this->assertEquals('Internal Server Error', $payload['message']); - } - - /** - * @test - * @dataProvider provideStatus - */ - public function errorStatusReturnsThatStatus(int $status, string $message): void - { - /** @var Response\JsonResponse $response */ - $response = $this->errorHandler->__invoke( - null, - new ServerRequest(), - (new Response())->withStatus($status, $message) - ); - $payload = $response->getPayload(); - - $this->assertInstanceOf(Response\JsonResponse::class, $response); - $this->assertEquals($status, $response->getStatusCode()); - $this->assertEquals($message, $payload['message']); - } - - public function provideStatus(): iterable - { - return array_map(function (int $status) { - return [$status, 'Some message']; - }, range(400, 500, 20)); - } -} From 4e5ab21a47722e84c3e8fd2854c53223ef3b22c9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Nov 2019 18:03:11 +0100 Subject: [PATCH 013/100] Removed whoops dev dependency --- composer.json | 1 - config/autoload/errorhandler.local.php.dist | 29 --------------------- 2 files changed, 30 deletions(-) delete mode 100644 config/autoload/errorhandler.local.php.dist diff --git a/composer.json b/composer.json index 4f4f3041..5b222782 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,6 @@ "require-dev": { "devster/ubench": "^2.0", "eaglewu/swoole-ide-helper": "dev-master", - "filp/whoops": "^2.4", "infection/infection": "^0.14.2", "phpstan/phpstan": "^0.11.16", "phpunit/phpcov": "^6.0", diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist deleted file mode 100644 index 6dea98cb..00000000 --- a/config/autoload/errorhandler.local.php.dist +++ /dev/null @@ -1,29 +0,0 @@ - [ - 'invokables' => [ - 'Zend\Expressive\Whoops' => Whoops\Run::class, - 'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class, - ], - ], - - 'whoops' => [ - 'json_exceptions' => [ - 'display' => true, - 'show_trace' => true, - 'ajax_only' => true, - ], - ], - - 'error_handler' => [ - 'plugins' => [ - 'factories' => [ - 'text/html' => WhoopsErrorResponseGeneratorFactory::class, - ], - ], - ], -]; From 74854b3dace92e178d35c2c811dc6d909fb98798 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Nov 2019 19:49:14 +0100 Subject: [PATCH 014/100] Added zend problem details to the project --- composer.json | 3 +- config/autoload/error-handler.global.php | 26 ++++++++++++++++ .../autoload/middleware-pipeline.global.php | 31 ++++++++++++------- config/config.php | 2 ++ module/Core/src/Response/NotFoundHandler.php | 9 ------ .../test/Response/NotFoundHandlerTest.php | 24 -------------- 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 config/autoload/error-handler.global.php diff --git a/composer.json b/composer.json index 5b222782..5d035b60 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "phly/phly-event-dispatcher": "^1.0", "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", - "shlinkio/shlink-common": "^2.2.1", + "shlinkio/shlink-common": "^2.3", "shlinkio/shlink-event-dispatcher": "^1.0", "shlinkio/shlink-installer": "^3.1", "shlinkio/shlink-ip-geolocation": "^1.1", @@ -52,6 +52,7 @@ "zendframework/zend-expressive-swoole": "^2.4", "zendframework/zend-inputfilter": "^2.10", "zendframework/zend-paginator": "^2.8", + "zendframework/zend-problem-details": "^1.0", "zendframework/zend-servicemanager": "^3.4", "zendframework/zend-stdlib": "^3.2" }, diff --git a/config/autoload/error-handler.global.php b/config/autoload/error-handler.global.php new file mode 100644 index 00000000..4ef36e0a --- /dev/null +++ b/config/autoload/error-handler.global.php @@ -0,0 +1,26 @@ + [ + 'listeners' => [Logger\ErrorLogger::class], + ], + + 'dependencies' => [ + 'delegators' => [ + ErrorHandler::class => [ + Logger\ErrorHandlerListenerAttachingDelegator::class, + ], + ProblemDetailsMiddleware::class => [ + Logger\ErrorHandlerListenerAttachingDelegator::class, + ], + ], + ], + +]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 9ea5e260..2508b191 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink; use Zend\Expressive; +use Zend\ProblemDetails; use Zend\Stratigility\Middleware\ErrorHandler; return [ @@ -14,20 +15,19 @@ return [ 'middleware' => [ ErrorHandler::class, ], - 'priority' => 15, ], -// 'error-handler-rest' => [ -// 'path' => '/rest', -// 'middleware' => [], -// 'priority' => 14, -// ], + 'error-handler-rest' => [ + 'path' => '/rest', + 'middleware' => [ + ProblemDetails\ProblemDetailsMiddleware::class, + ], + ], 'pre-routing' => [ 'middleware' => [ Expressive\Helper\ContentLengthMiddleware::class, Common\Middleware\CloseDbConnectionMiddleware::class, ], - 'priority' => 12, ], 'pre-routing-rest' => [ 'path' => '/rest', @@ -35,14 +35,12 @@ return [ Rest\Middleware\PathVersionMiddleware::class, Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class, ], - 'priority' => 11, ], 'routing' => [ 'middleware' => [ Expressive\Router\Middleware\RouteMiddleware::class, ], - 'priority' => 10, ], 'rest' => [ @@ -53,15 +51,24 @@ return [ Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class, ], - 'priority' => 5, ], - 'post-routing' => [ + 'dispatch' => [ 'middleware' => [ Expressive\Router\Middleware\DispatchMiddleware::class, + ], + ], + + 'not-found-rest' => [ + 'path' => '/rest', + 'middleware' => [ + ProblemDetails\ProblemDetailsNotFoundHandler::class, + ], + ], + 'not-found' => [ + 'middleware' => [ Core\Response\NotFoundHandler::class, ], - 'priority' => 1, ], ], ]; diff --git a/config/config.php b/config/config.php index 2c817d1e..ad18fd20 100644 --- a/config/config.php +++ b/config/config.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink; use Zend\ConfigAggregator; use Zend\Expressive; +use Zend\ProblemDetails; use function Shlinkio\Shlink\Common\env; @@ -15,6 +16,7 @@ return (new ConfigAggregator\ConfigAggregator([ Expressive\Router\FastRouteRouter\ConfigProvider::class, Expressive\Plates\ConfigProvider::class, Expressive\Swoole\ConfigProvider::class, + ProblemDetails\ConfigProvider::class, Common\ConfigProvider::class, IpGeolocation\ConfigProvider::class, Core\ConfigProvider::class, diff --git a/module/Core/src/Response/NotFoundHandler.php b/module/Core/src/Response/NotFoundHandler.php index 6bf245c1..7b288b54 100644 --- a/module/Core/src/Response/NotFoundHandler.php +++ b/module/Core/src/Response/NotFoundHandler.php @@ -18,7 +18,6 @@ use Zend\Expressive\Template\TemplateRendererInterface; use function array_shift; use function explode; -use function Functional\contains; use function rtrim; class NotFoundHandler implements RequestHandlerInterface @@ -64,14 +63,6 @@ class NotFoundHandler implements RequestHandlerInterface $accept = array_shift($accepts); $status = StatusCodeInterface::STATUS_NOT_FOUND; - // If the first accepted type is json, return a json response - if (contains(['application/json', 'text/json', 'application/x-json'], $accept)) { - return new Response\JsonResponse([ - 'error' => 'NOT_FOUND', - 'message' => 'Not found', - ], $status); - } - $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; return new Response\HtmlResponse($this->renderer->render($template), $status); } diff --git a/module/Core/test/Response/NotFoundHandlerTest.php b/module/Core/test/Response/NotFoundHandlerTest.php index 8f1d6f51..50211229 100644 --- a/module/Core/test/Response/NotFoundHandlerTest.php +++ b/module/Core/test/Response/NotFoundHandlerTest.php @@ -13,7 +13,6 @@ use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Response\NotFoundHandler; use Zend\Diactoros\Response; -use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequestFactory; use Zend\Diactoros\Uri; use Zend\Expressive\Router\Route; @@ -37,29 +36,6 @@ class NotFoundHandlerTest extends TestCase $this->delegate = new NotFoundHandler($this->renderer->reveal(), $this->redirectOptions, ''); } - /** - * @test - * @dataProvider provideResponses - */ - public function properResponseTypeIsReturned(string $expectedResponse, string $accept, int $renderCalls): void - { - $request = (new ServerRequest())->withHeader('Accept', $accept); - $render = $this->renderer->render(Argument::cetera())->willReturn(''); - - $resp = $this->delegate->handle($request); - - $this->assertInstanceOf($expectedResponse, $resp); - $render->shouldHaveBeenCalledTimes($renderCalls); - } - - public function provideResponses(): iterable - { - yield 'application/json' => [Response\JsonResponse::class, 'application/json', 0]; - yield 'text/json' => [Response\JsonResponse::class, 'text/json', 0]; - yield 'application/x-json' => [Response\JsonResponse::class, 'application/x-json', 0]; - yield 'text/html' => [Response\HtmlResponse::class, 'text/html', 1]; - } - /** * @test * @dataProvider provideRedirects From 89e373f775a9bd80017bc09f5bcc4fe6209e64ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Nov 2019 10:11:34 +0100 Subject: [PATCH 015/100] Moved NotFoundHandler to ErrorHandler namespace --- config/autoload/middleware-pipeline.global.php | 2 +- module/Core/config/dependencies.config.php | 2 +- .../Core/src/{Response => ErrorHandler}/NotFoundHandler.php | 2 +- .../test/{Response => ErrorHandler}/NotFoundHandlerTest.php | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename module/Core/src/{Response => ErrorHandler}/NotFoundHandler.php (98%) rename module/Core/test/{Response => ErrorHandler}/NotFoundHandlerTest.php (97%) diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 2508b191..d43eed5b 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -67,7 +67,7 @@ return [ ], 'not-found' => [ 'middleware' => [ - Core\Response\NotFoundHandler::class, + Core\ErrorHandler\NotFoundHandler::class, ], ], ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 426d0969..55391fa8 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core; use Doctrine\Common\Cache\Cache; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Core\ErrorHandler\NotFoundHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use Shlinkio\Shlink\Core\Response\NotFoundHandler; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; use Zend\Expressive\Router\RouterInterface; use Zend\Expressive\Template\TemplateRendererInterface; diff --git a/module/Core/src/Response/NotFoundHandler.php b/module/Core/src/ErrorHandler/NotFoundHandler.php similarity index 98% rename from module/Core/src/Response/NotFoundHandler.php rename to module/Core/src/ErrorHandler/NotFoundHandler.php index 7b288b54..e409c906 100644 --- a/module/Core/src/Response/NotFoundHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Response; +namespace Shlinkio\Shlink\Core\ErrorHandler; use Fig\Http\Message\StatusCodeInterface; use InvalidArgumentException; diff --git a/module/Core/test/Response/NotFoundHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundHandlerTest.php similarity index 97% rename from module/Core/test/Response/NotFoundHandlerTest.php rename to module/Core/test/ErrorHandler/NotFoundHandlerTest.php index 50211229..5806e5ec 100644 --- a/module/Core/test/Response/NotFoundHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundHandlerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Response; +namespace ShlinkioTest\Shlink\Core\ErrorHandler; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -10,8 +10,8 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\ErrorHandler\NotFoundHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use Shlinkio\Shlink\Core\Response\NotFoundHandler; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequestFactory; use Zend\Diactoros\Uri; From 1bafe54a756754e164cff6a207292a368bcffbb3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Nov 2019 10:25:12 +0100 Subject: [PATCH 016/100] Split NotFoundHandler into two different middlewares --- .../autoload/middleware-pipeline.global.php | 3 +- module/Core/config/dependencies.config.php | 12 ++-- ...andler.php => NotFoundRedirectHandler.php} | 41 ++----------- .../ErrorHandler/NotFoundTemplateHandler.php | 46 +++++++++++++++ ...st.php => NotFoundRedirectHandlerTest.php} | 50 +++------------- .../NotFoundTemplateHandlerTest.php | 59 +++++++++++++++++++ 6 files changed, 125 insertions(+), 86 deletions(-) rename module/Core/src/ErrorHandler/{NotFoundHandler.php => NotFoundRedirectHandler.php} (57%) create mode 100644 module/Core/src/ErrorHandler/NotFoundTemplateHandler.php rename module/Core/test/ErrorHandler/{NotFoundHandlerTest.php => NotFoundRedirectHandlerTest.php} (59%) create mode 100644 module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index d43eed5b..6013f56a 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -67,7 +67,8 @@ return [ ], 'not-found' => [ 'middleware' => [ - Core\ErrorHandler\NotFoundHandler::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 55391fa8..0ebdae7d 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core; use Doctrine\Common\Cache\Cache; use Psr\EventDispatcher\EventDispatcherInterface; -use Shlinkio\Shlink\Core\ErrorHandler\NotFoundHandler; +use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; use Zend\Expressive\Router\RouterInterface; @@ -17,7 +17,8 @@ return [ 'dependencies' => [ 'factories' => [ - NotFoundHandler::class => ConfigAbstractFactory::class, + ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class, + ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class, Options\AppOptions::class => ConfigAbstractFactory::class, Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, @@ -43,11 +44,8 @@ return [ ], ConfigAbstractFactory::class => [ - NotFoundHandler::class => [ - TemplateRendererInterface::class, - NotFoundRedirectOptions::class, - 'config.router.base_path', - ], + ErrorHandler\NotFoundRedirectHandler::class => [NotFoundRedirectOptions::class, 'config.router.base_path'], + ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class], Options\AppOptions::class => ['config.app_options'], Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], diff --git a/module/Core/src/ErrorHandler/NotFoundHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php similarity index 57% rename from module/Core/src/ErrorHandler/NotFoundHandler.php rename to module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index e409c906..f6a03395 100644 --- a/module/Core/src/ErrorHandler/NotFoundHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -4,67 +4,38 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ErrorHandler; -use Fig\Http\Message\StatusCodeInterface; -use InvalidArgumentException; 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\Options\NotFoundRedirectOptions; use Zend\Diactoros\Response; use Zend\Expressive\Router\RouteResult; -use Zend\Expressive\Template\TemplateRendererInterface; -use function array_shift; -use function explode; use function rtrim; -class NotFoundHandler implements RequestHandlerInterface +class NotFoundRedirectHandler implements MiddlewareInterface { - public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404'; - public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code'; - - /** @var TemplateRendererInterface */ - private $renderer; /** @var NotFoundRedirectOptions */ private $redirectOptions; /** @var string */ private $shlinkBasePath; - public function __construct( - TemplateRendererInterface $renderer, - NotFoundRedirectOptions $redirectOptions, - string $shlinkBasePath - ) { - $this->renderer = $renderer; + public function __construct(NotFoundRedirectOptions $redirectOptions, string $shlinkBasePath) + { $this->redirectOptions = $redirectOptions; $this->shlinkBasePath = $shlinkBasePath; } - /** - * Dispatch the next available middleware and return the response. - * - * @param ServerRequestInterface $request - * - * @return ResponseInterface - * @throws InvalidArgumentException - */ - public function handle(ServerRequestInterface $request): ResponseInterface + 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()); - if ($redirectResponse !== null) { - return $redirectResponse; - } - $accepts = explode(',', $request->getHeaderLine('Accept')); - $accept = array_shift($accepts); - $status = StatusCodeInterface::STATUS_NOT_FOUND; - - $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; - return new Response\HtmlResponse($this->renderer->render($template), $status); + return $redirectResponse ?? $handler->handle($request); } private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php new file mode 100644 index 00000000..7b84043d --- /dev/null +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -0,0 +1,46 @@ +renderer = $renderer; + } + + /** + * Dispatch the next available middleware and return the response. + * + * @param ServerRequestInterface $request + * + * @return ResponseInterface + * @throws InvalidArgumentException + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + /** @var RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); + $status = StatusCodeInterface::STATUS_NOT_FOUND; + + $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; + return new Response\HtmlResponse($this->renderer->render($template), $status); + } +} diff --git a/module/Core/test/ErrorHandler/NotFoundHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php similarity index 59% rename from module/Core/test/ErrorHandler/NotFoundHandlerTest.php rename to module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 5806e5ec..d2776179 100644 --- a/module/Core/test/ErrorHandler/NotFoundHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -5,35 +5,29 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; 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\NotFoundHandler; +use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequestFactory; use Zend\Diactoros\Uri; use Zend\Expressive\Router\Route; use Zend\Expressive\Router\RouteResult; -use Zend\Expressive\Template\TemplateRendererInterface; -class NotFoundHandlerTest extends TestCase +class NotFoundRedirectHandlerTest extends TestCase { - /** @var NotFoundHandler */ - private $delegate; - /** @var ObjectProphecy */ - private $renderer; + /** @var NotFoundRedirectHandler */ + private $middleware; /** @var NotFoundRedirectOptions */ private $redirectOptions; public function setUp(): void { - $this->renderer = $this->prophesize(TemplateRendererInterface::class); $this->redirectOptions = new NotFoundRedirectOptions(); - - $this->delegate = new NotFoundHandler($this->renderer->reveal(), $this->redirectOptions, ''); + $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, ''); } /** @@ -48,11 +42,10 @@ class NotFoundHandlerTest extends TestCase $this->redirectOptions->regular404 = 'regular404'; $this->redirectOptions->baseUrl = 'baseUrl'; - $resp = $this->delegate->handle($request); + $resp = $this->middleware->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); $this->assertInstanceOf(Response\RedirectResponse::class, $resp); $this->assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location')); - $this->renderer->render(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideRedirects(): iterable @@ -86,33 +79,4 @@ class NotFoundHandlerTest extends TestCase 'invalidShortUrl', ]; } - - /** - * @test - * @dataProvider provideTemplates - */ - public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void - { - $request = $request->withHeader('Accept', 'text/html'); - $render = $this->renderer->render($expectedTemplate)->willReturn(''); - - $resp = $this->delegate->handle($request); - - $this->assertInstanceOf(Response\HtmlResponse::class, $resp); - $render->shouldHaveBeenCalledOnce(); - } - - public function provideTemplates(): iterable - { - $request = ServerRequestFactory::fromGlobals(); - - yield [$request, NotFoundHandler::NOT_FOUND_TEMPLATE]; - yield [ - $request->withAttribute( - RouteResult::class, - RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal())) - ), - NotFoundHandler::INVALID_SHORT_CODE_TEMPLATE, - ]; - } } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php new file mode 100644 index 00000000..7d763448 --- /dev/null +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -0,0 +1,59 @@ +renderer = $this->prophesize(TemplateRendererInterface::class); + $this->handler = new NotFoundTemplateHandler($this->renderer->reveal()); + } + + /** + * @test + * @dataProvider provideTemplates + */ + public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void + { + $request = $request->withHeader('Accept', 'text/html'); + $render = $this->renderer->render($expectedTemplate)->willReturn(''); + + $resp = $this->handler->handle($request); + + $this->assertInstanceOf(Response\HtmlResponse::class, $resp); + $render->shouldHaveBeenCalledOnce(); + } + + public function provideTemplates(): iterable + { + $request = ServerRequestFactory::fromGlobals(); + + yield [$request, NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; + yield [ + $request->withAttribute( + RouteResult::class, + RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal())) + ), + NotFoundTemplateHandler::INVALID_SHORT_CODE_TEMPLATE, + ]; + } +} From 850259290aef8de687220be9dc0663a8a1d16cd0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Nov 2019 10:28:58 +0100 Subject: [PATCH 017/100] Covered new use case on NotFoundRedirectHandlerTest --- .../NotFoundRedirectHandlerTest.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index d2776179..b7796e61 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -42,10 +42,14 @@ class NotFoundRedirectHandlerTest extends TestCase $this->redirectOptions->regular404 = 'regular404'; $this->redirectOptions->baseUrl = 'baseUrl'; - $resp = $this->middleware->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); + $next = $this->prophesize(RequestHandlerInterface::class); + $handle = $next->handle($request)->willReturn(new Response()); + + $resp = $this->middleware->process($request, $next->reveal()); $this->assertInstanceOf(Response\RedirectResponse::class, $resp); $this->assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location')); + $handle->shouldNotHaveBeenCalled(); } public function provideRedirects(): iterable @@ -79,4 +83,19 @@ class NotFoundRedirectHandlerTest extends TestCase 'invalidShortUrl', ]; } + + /** @test */ + public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void + { + $req = ServerRequestFactory::fromGlobals(); + $resp = new Response(); + + $next = $this->prophesize(RequestHandlerInterface::class); + $handle = $next->handle($req)->willReturn($resp); + + $result = $this->middleware->process($req, $next->reveal()); + + $this->assertSame($resp, $result); + $handle->shouldHaveBeenCalledOnce(); + } } From 09321eaa93a3fae6f30f01ce1606268e9f4027b8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 23 Nov 2019 13:41:07 +0100 Subject: [PATCH 018/100] Updated InvalidShortCodeException to implement ProblemDetails --- .../Exception/InvalidShortCodeException.php | 31 ++++++++++++------- .../InvalidShortCodeExceptionTest.php | 23 +------------- .../Action/ShortUrl/DeleteShortUrlAction.php | 9 ------ module/Rest/src/Util/RestUtils.php | 4 ++- .../ShortUrl/DeleteShortUrlActionTest.php | 1 - 5 files changed, 23 insertions(+), 45 deletions(-) diff --git a/module/Core/src/Exception/InvalidShortCodeException.php b/module/Core/src/Exception/InvalidShortCodeException.php index 37ecfffb..77240aca 100644 --- a/module/Core/src/Exception/InvalidShortCodeException.php +++ b/module/Core/src/Exception/InvalidShortCodeException.php @@ -4,24 +4,31 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; -use Throwable; +use Fig\Http\Message\StatusCodeInterface; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function sprintf; -class InvalidShortCodeException extends RuntimeException +class InvalidShortCodeException extends RuntimeException implements ProblemDetailsExceptionInterface { - public static function fromCharset(string $shortCode, string $charSet, ?Throwable $previous = null): self - { - $code = $previous !== null ? $previous->getCode() : -1; - return new static( - sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet), - $code, - $previous - ); - } + use CommonProblemDetailsExceptionTrait; + + public const TITLE = 'Invalid short code'; + public const TYPE = 'INVALID_SHORTCODE'; public static function fromNotFoundShortCode(string $shortCode): self { - return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode)); + $e = new self(sprintf('No URL found for short code "%s"', $shortCode)); + $e->detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + $e->additional = [ + 'error' => $e->type, + 'message' => $e->detail, + ]; + + return $e; } } diff --git a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php b/module/Core/test/Exception/InvalidShortCodeExceptionTest.php index 02a3edf2..4639fda3 100644 --- a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php +++ b/module/Core/test/Exception/InvalidShortCodeExceptionTest.php @@ -4,37 +4,16 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Exception; -use Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; -use Throwable; class InvalidShortCodeExceptionTest extends TestCase { - /** - * @test - * @dataProvider providePrevious - */ - public function properlyCreatesExceptionFromCharset(?Throwable $prev): void - { - $e = InvalidShortCodeException::fromCharset('abc123', 'def456', $prev); - - $this->assertEquals('Provided short code "abc123" does not match the char set "def456"', $e->getMessage()); - $this->assertEquals($prev !== null ? $prev->getCode() : -1, $e->getCode()); - $this->assertEquals($prev, $e->getPrevious()); - } - - public function providePrevious(): iterable - { - yield 'null previous' => [null]; - yield 'instance previous' => [new Exception('Previous error', 10)]; - } - /** @test */ public function properlyCreatesExceptionFromNotFoundShortCode(): void { $e = InvalidShortCodeException::fromNotFoundShortCode('abc123'); - $this->assertEquals('Provided short code "abc123" does not belong to a short URL', $e->getMessage()); + $this->assertEquals('No URL found for short code "abc123"', $e->getMessage()); } } diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index 54755baa..f6873025 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -40,15 +40,6 @@ class DeleteShortUrlAction extends AbstractRestAction try { $this->deleteShortUrlService->deleteByShortCode($shortCode); return new EmptyResponse(); - } catch (Exception\InvalidShortCodeException $e) { - $this->logger->warning( - 'Provided short code {shortCode} does not belong to any URL. {e}', - ['e' => $e, 'shortCode' => $shortCode] - ); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('No URL found for short code "%s"', $shortCode), - ], self::STATUS_NOT_FOUND); } catch (Exception\DeleteShortUrlException $e) { $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); $messagePlaceholder = diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 60aade54..71c65e88 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -6,12 +6,14 @@ namespace Shlinkio\Shlink\Rest\Util; use Shlinkio\Shlink\Common\Exception as Common; use Shlinkio\Shlink\Core\Exception as Core; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Rest\Exception as Rest; use Throwable; class RestUtils { - public const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE'; + /** @deprecated */ + public const INVALID_SHORTCODE_ERROR = InvalidShortCodeException::TYPE; // FIXME Should be INVALID_SHORT_URL_DELETION public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION'; public const INVALID_URL_ERROR = 'INVALID_URL'; diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 35f87dc8..2e77d10a 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -59,7 +59,6 @@ class DeleteShortUrlActionTest extends TestCase public function provideExceptions(): iterable { - yield 'not found' => [new Exception\InvalidShortCodeException(), RestUtils::INVALID_SHORTCODE_ERROR, 404]; yield 'bad request' => [ new Exception\DeleteShortUrlException(5), RestUtils::INVALID_SHORTCODE_DELETION_ERROR, From 6f0afe269d63dfdcc9dd410171ed1537ebe8c6b6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 12:41:12 +0100 Subject: [PATCH 019/100] Moved InvalidShortCode exception handling to problem details --- config/autoload/error-handler.global.php | 8 ++ .../autoload/middleware-pipeline.global.php | 3 +- .../Exception/InvalidShortCodeException.php | 7 +- module/Rest/config/dependencies.config.php | 6 ++ .../Action/ShortUrl/EditShortUrlAction.php | 8 -- .../ShortUrl/EditShortUrlTagsAction.php | 14 +-- .../Rest/src/Action/Visit/GetVisitsAction.php | 5 -- module/Rest/src/ConfigProvider.php | 2 +- ...ardsCompatibleProblemDetailsMiddleware.php | 87 +++++++++++++++++++ .../src/Middleware/PathVersionMiddleware.php | 13 +-- .../ShortUrl/EditShortUrlActionTest.php | 26 +----- .../ShortUrl/EditShortUrlTagsActionTest.php | 19 +--- 12 files changed, 113 insertions(+), 85 deletions(-) create mode 100644 module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php diff --git a/config/autoload/error-handler.global.php b/config/autoload/error-handler.global.php index 4ef36e0a..964b90de 100644 --- a/config/autoload/error-handler.global.php +++ b/config/autoload/error-handler.global.php @@ -8,6 +8,14 @@ use Zend\Stratigility\Middleware\ErrorHandler; return [ + 'backwards_compatible_problem_details' => [ + 'default_type_fallbacks' => [ + 404 => 'NOT_FOUND', + 500 => 'INTERNAL_SERVER_ERROR', + ], + 'json_flags' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION, + ], + 'error_handler' => [ 'listeners' => [Logger\ErrorLogger::class], ], diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 6013f56a..f3370a7a 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -13,19 +13,20 @@ return [ 'middleware_pipeline' => [ 'error-handler' => [ 'middleware' => [ + Expressive\Helper\ContentLengthMiddleware::class, ErrorHandler::class, ], ], 'error-handler-rest' => [ 'path' => '/rest', 'middleware' => [ + Rest\Middleware\BackwardsCompatibleProblemDetailsMiddleware::class, ProblemDetails\ProblemDetailsMiddleware::class, ], ], 'pre-routing' => [ 'middleware' => [ - Expressive\Helper\ContentLengthMiddleware::class, Common\Middleware\CloseDbConnectionMiddleware::class, ], ], diff --git a/module/Core/src/Exception/InvalidShortCodeException.php b/module/Core/src/Exception/InvalidShortCodeException.php index 77240aca..d75cdad7 100644 --- a/module/Core/src/Exception/InvalidShortCodeException.php +++ b/module/Core/src/Exception/InvalidShortCodeException.php @@ -14,20 +14,17 @@ class InvalidShortCodeException extends RuntimeException implements ProblemDetai { use CommonProblemDetailsExceptionTrait; - public const TITLE = 'Invalid short code'; + private const TITLE = 'Invalid short code'; public const TYPE = 'INVALID_SHORTCODE'; public static function fromNotFoundShortCode(string $shortCode): self { $e = new self(sprintf('No URL found for short code "%s"', $shortCode)); + $e->detail = $e->getMessage(); $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_NOT_FOUND; - $e->additional = [ - 'error' => $e->type, - 'message' => $e->detail, - ]; return $e; } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 98a26bc1..17c30642 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -39,6 +39,7 @@ return [ Middleware\BodyParserMiddleware::class => InvokableFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\PathVersionMiddleware::class => InvokableFactory::class, + Middleware\BackwardsCompatibleProblemDetailsMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\ShortCodePathMiddleware::class => InvokableFactory::class, ], @@ -75,6 +76,11 @@ return [ Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class], + + Middleware\BackwardsCompatibleProblemDetailsMiddleware::class => [ + 'config.backwards_compatible_problem_details.default_type_fallbacks', + 'config.backwards_compatible_problem_details.json_flags', + ], ], ]; diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index fe609e03..8391db8a 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -15,8 +15,6 @@ use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\JsonResponse; -use function sprintf; - class EditShortUrlAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}'; @@ -51,12 +49,6 @@ class EditShortUrlAction extends AbstractRestAction ShortUrlMeta::createFromRawData($postData) ); return new EmptyResponse(); - } catch (Exception\InvalidShortCodeException $e) { - $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('No URL found for short code "%s"', $shortCode), - ], self::STATUS_NOT_FOUND); } catch (Exception\ValidationException $e) { $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); return new JsonResponse([ diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 4daa0fac..9d82c608 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -7,14 +7,11 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; -use function sprintf; - class EditShortUrlTagsAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}/tags'; @@ -47,14 +44,7 @@ class EditShortUrlTagsAction extends AbstractRestAction } $tags = $bodyParams['tags']; - try { - $shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags); - return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); - } catch (InvalidShortCodeException $e) { - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('No URL found for short code "%s"', $shortCode), - ], self::STATUS_NOT_FOUND); - } + $shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags); + return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); } } diff --git a/module/Rest/src/Action/Visit/GetVisitsAction.php b/module/Rest/src/Action/Visit/GetVisitsAction.php index 68912be2..ed614ae7 100644 --- a/module/Rest/src/Action/Visit/GetVisitsAction.php +++ b/module/Rest/src/Action/Visit/GetVisitsAction.php @@ -33,11 +33,6 @@ class GetVisitsAction extends AbstractRestAction $this->visitsTracker = $visitsTracker; } - /** - * @param Request $request - * @return Response - * @throws \InvalidArgumentException - */ public function handle(Request $request): Response { $shortCode = $request->getAttribute('shortCode'); diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 11e6f811..d942cf51 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -11,7 +11,7 @@ use function sprintf; class ConfigProvider { - private const ROUTES_PREFIX = '/rest/v{version:1}'; + private const ROUTES_PREFIX = '/rest/v{version:1|2}'; public function __invoke() { diff --git a/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php b/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php new file mode 100644 index 00000000..9857465b --- /dev/null +++ b/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php @@ -0,0 +1,87 @@ + 'type', + 'message' => 'detail', + ]; + + /** @var array */ + private $defaultTypeFallbacks; + /** @var int */ + private $jsonFlags; + + public function __construct(array $defaultTypeFallbacks, int $jsonFlags) + { + $this->defaultTypeFallbacks = $defaultTypeFallbacks; + $this->jsonFlags = $jsonFlags; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $resp = $handler->handle($request); + + if ($resp->getHeaderLine('Content-type') !== 'application/problem+json') { + return $resp; + } + + try { + $body = (string) $resp->getBody(); + $payload = json_decode($body); + } catch (Throwable $e) { + return $resp; + } + + $payload = $this->mapStandardErrorTypes($payload, $resp->getStatusCode()); + + if ($this->isVersionOne($request)) { + $payload = $this->makePayloadBackwardsCompatible($payload); + } + + return new JsonResponse($payload, $resp->getStatusCode(), $resp->getHeaders(), $this->jsonFlags); + } + + private function mapStandardErrorTypes(array $payload, int $respStatusCode): array + { + $type = $payload['type'] ?? ''; + if (strpos($type, 'https://httpstatus.es') === 0) { + $payload['type'] = $this->defaultTypeFallbacks[$respStatusCode] ?? $type; + } + + return $payload; + } + + /** @deprecated When Shlink 2 is released, do not chekc the version */ + private function isVersionOne(ServerRequestInterface $request): bool + { + $uri = $request->getUri(); + $path = $uri->getPath(); + + return strpos($path, '/v') === false || strpos($path, '/v1') === 0; + } + + /** @deprecated When Shlink v2 is released, do not map old fields */ + private function makePayloadBackwardsCompatible(array $payload): array + { + return reduce_left(self::BACKWARDS_COMPATIBLE_FIELDS, function (string $newKey, string $oldKey, $c, $acc) { + $acc[$oldKey] = $acc[$newKey]; + return $acc; + }, $payload); + } +} diff --git a/module/Rest/src/Middleware/PathVersionMiddleware.php b/module/Rest/src/Middleware/PathVersionMiddleware.php index c08c8dbb..84921710 100644 --- a/module/Rest/src/Middleware/PathVersionMiddleware.php +++ b/module/Rest/src/Middleware/PathVersionMiddleware.php @@ -15,23 +15,12 @@ class PathVersionMiddleware implements MiddlewareInterface { // TODO The /health endpoint needs this middleware in order to work without the version. // Take it into account if this middleware is ever removed. - - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * @param Request $request - * @param RequestHandlerInterface $handler - * - * @return Response - * @throws \InvalidArgumentException - */ public function process(Request $request, RequestHandlerInterface $handler): Response { $uri = $request->getUri(); $path = $uri->getPath(); - // If the path does not begin with the version number, prepend v1 by default for BC compatibility purposes + // If the path does not begin with the version number, prepend v1 by default for BC purposes if (strpos($path, '/v') !== 0) { $request = $request->withUri($uri->withPath('/v1' . $uri->getPath())); } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index d8e62c20..55fcc2d7 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; use Shlinkio\Shlink\Rest\Util\RestUtils; @@ -29,7 +28,7 @@ class EditShortUrlActionTest extends TestCase } /** @test */ - public function invalidDataReturnsError() + public function invalidDataReturnsError(): void { $request = (new ServerRequest())->withParsedBody([ 'maxVisits' => 'invalid', @@ -45,28 +44,7 @@ class EditShortUrlActionTest extends TestCase } /** @test */ - public function incorrectShortCodeReturnsError() - { - $request = (new ServerRequest())->withAttribute('shortCode', 'abc123') - ->withParsedBody([ - 'maxVisits' => 5, - ]); - $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willThrow( - InvalidShortCodeException::class - ); - - /** @var JsonResponse $resp */ - $resp = $this->action->handle($request); - $payload = $resp->getPayload(); - - $this->assertEquals(404, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $payload['error']); - $this->assertEquals('No URL found for short code "abc123"', $payload['message']); - $updateMeta->shouldHaveBeenCalled(); - } - - /** @test */ - public function correctShortCodeReturnsSuccess() + public function correctShortCodeReturnsSuccess(): void { $request = (new ServerRequest())->withAttribute('shortCode', 'abc123') ->withParsedBody([ diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index d56bbaf1..5c0ec628 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; use Zend\Diactoros\ServerRequest; @@ -26,28 +25,14 @@ class EditShortUrlTagsActionTest extends TestCase } /** @test */ - public function notProvidingTagsReturnsError() + public function notProvidingTagsReturnsError(): void { $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', 'abc123')); $this->assertEquals(400, $response->getStatusCode()); } /** @test */ - public function anInvalidShortCodeReturnsNotFound() - { - $shortCode = 'abc123'; - $this->shortUrlService->setTagsByShortCode($shortCode, [])->willThrow(InvalidShortCodeException::class) - ->shouldBeCalledOnce(); - - $response = $this->action->handle( - (new ServerRequest())->withAttribute('shortCode', 'abc123') - ->withParsedBody(['tags' => []]) - ); - $this->assertEquals(404, $response->getStatusCode()); - } - - /** @test */ - public function tagsListIsReturnedIfCorrectShortCodeIsProvided() + public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void { $shortCode = 'abc123'; $this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl('')) From cdd36b6712eeecf331e96f1a3196b2b366fe8818 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 13:24:52 +0100 Subject: [PATCH 020/100] Created BackwardsCompatibleProblemDetailsMiddlewareTest --- ...ardsCompatibleProblemDetailsMiddleware.php | 5 +- ...CompatibleProblemDetailsMiddlewareTest.php | 122 ++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 module/Rest/test/Middleware/BackwardsCompatibleProblemDetailsMiddlewareTest.php diff --git a/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php b/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php index 9857465b..0812c7e0 100644 --- a/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php +++ b/module/Rest/src/Middleware/BackwardsCompatibleProblemDetailsMiddleware.php @@ -36,7 +36,6 @@ class BackwardsCompatibleProblemDetailsMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $resp = $handler->handle($request); - if ($resp->getHeaderLine('Content-type') !== 'application/problem+json') { return $resp; } @@ -70,9 +69,7 @@ class BackwardsCompatibleProblemDetailsMiddleware implements MiddlewareInterface /** @deprecated When Shlink 2 is released, do not chekc the version */ private function isVersionOne(ServerRequestInterface $request): bool { - $uri = $request->getUri(); - $path = $uri->getPath(); - + $path = $request->getUri()->getPath(); return strpos($path, '/v') === false || strpos($path, '/v1') === 0; } diff --git a/module/Rest/test/Middleware/BackwardsCompatibleProblemDetailsMiddlewareTest.php b/module/Rest/test/Middleware/BackwardsCompatibleProblemDetailsMiddlewareTest.php new file mode 100644 index 00000000..4d47c4cb --- /dev/null +++ b/module/Rest/test/Middleware/BackwardsCompatibleProblemDetailsMiddlewareTest.php @@ -0,0 +1,122 @@ +handler = $this->prophesize(RequestHandlerInterface::class); + $this->middleware = new BackwardsCompatibleProblemDetailsMiddleware([ + 404 => 'NOT_FOUND', + 500 => 'INTERNAL_SERVER_ERROR', + ], 0); + } + + /** + * @test + * @dataProvider provideNonProcessableResponses + */ + public function nonProblemDetailsOrInvalidResponsesAreReturnedAsTheyAre(Response $response): void + { + $request = ServerRequestFactory::fromGlobals(); + $response = new Response(); + $handle = $this->handler->handle($request)->willReturn($response); + + $result = $this->middleware->process($request, $this->handler->reveal()); + + $this->assertSame($response, $result); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideNonProcessableResponses(): iterable + { + yield 'no problem details' => [new Response()]; + yield 'invalid JSON' => [(new Response('data://text/plain,{invalid-json'))->withHeader( + 'Content-Type', + 'application/problem+json' + )]; + } + + /** + * @test + * @dataProvider provideStatusAndTypes + */ + public function properlyMapsTypesBasedOnResponseStatus(Response\JsonResponse $response, string $expectedType): void + { + $request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/v2/something')); + $handle = $this->handler->handle($request)->willReturn($response); + + /** @var Response\JsonResponse $result */ + $result = $this->middleware->process($request, $this->handler->reveal()); + $payload = $result->getPayload(); + + $this->assertEquals($expectedType, $payload['type']); + $this->assertArrayNotHasKey('error', $payload); + $this->assertArrayNotHasKey('message', $payload); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideStatusAndTypes(): iterable + { + yield [$this->jsonResponse(['type' => 'https://httpstatus.es/404'], 404), 'NOT_FOUND']; + yield [$this->jsonResponse(['type' => 'https://httpstatus.es/500'], 500), 'INTERNAL_SERVER_ERROR']; + yield [$this->jsonResponse(['type' => 'https://httpstatus.es/504'], 504), 'https://httpstatus.es/504']; + yield [$this->jsonResponse(['type' => 'something_else'], 404), 'something_else']; + yield [$this->jsonResponse(['type' => 'something_else'], 500), 'something_else']; + yield [$this->jsonResponse(['type' => 'something_else'], 504), 'something_else']; + } + + /** + * @test + * @dataProvider provideRequestPath + */ + public function mapsDeprecatedPropertiesWhenRequestIsPerformedToVersionOne(string $requestPath): void + { + $request = ServerRequestFactory::fromGlobals()->withUri(new Uri($requestPath)); + $response = $this->jsonResponse([ + 'type' => 'the_type', + 'detail' => 'the_detail', + ]); + $handle = $this->handler->handle($request)->willReturn($response); + + /** @var Response\JsonResponse $result */ + $result = $this->middleware->process($request, $this->handler->reveal()); + $payload = $result->getPayload(); + + $this->assertEquals([ + 'type' => 'the_type', + 'detail' => 'the_detail', + 'error' => 'the_type', + 'message' => 'the_detail', + ], $payload); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideRequestPath(): iterable + { + yield 'no version' => ['/foo']; + yield 'version one' => ['/v1/foo']; + } + + private function jsonResponse(array $payload, int $status = 200): Response\JsonResponse + { + $headers = ['Content-Type' => 'application/problem+json']; + return new Response\JsonResponse($payload, $status, $headers); + } +} From 2f1de4a162f224107a978c9ed9ca1aaf89f1fec7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 23:11:25 +0100 Subject: [PATCH 021/100] Renamed InvalidShortCodeException to ShortCodeNotFoundException --- .../src/Command/ShortUrl/DeleteShortUrlCommand.php | 2 +- module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php | 4 ++-- .../Command/ShortUrl/DeleteShortUrlCommandTest.php | 2 +- .../test/Command/ShortUrl/ResolveUrlCommandTest.php | 4 ++-- module/Core/src/Action/AbstractTrackingAction.php | 4 ++-- module/Core/src/Action/PreviewAction.php | 4 ++-- module/Core/src/Action/QrCodeAction.php | 4 ++-- module/Core/src/Exception/DomainException.php | 11 +++++++++++ ...odeException.php => ShortUrlNotFoundException.php} | 4 ++-- .../src/Service/ShortUrl/DeleteShortUrlService.php | 2 +- .../ShortUrl/DeleteShortUrlServiceInterface.php | 2 +- .../Core/src/Service/ShortUrl/FindShortCodeTrait.php | 6 +++--- module/Core/src/Service/ShortUrlService.php | 6 +++--- module/Core/src/Service/ShortUrlServiceInterface.php | 6 +++--- module/Core/src/Service/VisitsTracker.php | 6 +++--- module/Core/src/Service/VisitsTrackerInterface.php | 4 ++-- module/Core/test/Action/PreviewActionTest.php | 4 ++-- module/Core/test/Action/QrCodeActionTest.php | 4 ++-- .../test/Exception/InvalidShortCodeExceptionTest.php | 4 ++-- module/Core/test/Service/ShortUrlServiceTest.php | 4 ++-- module/Rest/src/Action/Visit/GetVisitsAction.php | 4 ++-- module/Rest/src/Util/RestUtils.php | 6 +++--- module/Rest/test/Action/Visit/GetVisitsActionTest.php | 4 ++-- module/Rest/test/Util/RestUtilsTest.php | 4 ++-- 24 files changed, 58 insertions(+), 47 deletions(-) create mode 100644 module/Core/src/Exception/DomainException.php rename module/Core/src/Exception/{InvalidShortCodeException.php => ShortUrlNotFoundException.php} (82%) diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 683fd893..f44e3e18 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -55,7 +55,7 @@ class DeleteShortUrlCommand extends Command try { $this->runDelete($io, $shortCode, $ignoreThreshold); return ExitCodes::EXIT_SUCCESS; - } catch (Exception\InvalidShortCodeException $e) { + } catch (Exception\ShortUrlNotFoundException $e) { $io->error(sprintf('Provided short code "%s" could not be found.', $shortCode)); return ExitCodes::EXIT_FAILURE; } catch (Exception\DeleteShortUrlException $e) { diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index bc159a79..b758ddd4 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -65,7 +65,7 @@ class ResolveUrlCommand extends Command $url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return ExitCodes::EXIT_SUCCESS; - } catch (InvalidShortCodeException $e) { + } catch (ShortUrlNotFoundException $e) { $io->error(sprintf('Provided short code "%s" has an invalid format.', $shortCode)); return ExitCodes::EXIT_FAILURE; } catch (EntityDoesNotExistException $e) { diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index f0fad2cc..76414481 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -58,7 +58,7 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( - Exception\InvalidShortCodeException::class + Exception\ShortUrlNotFoundException::class ); $this->commandTester->execute(['shortCode' => $shortCode]); diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 283b3a4f..9e86bbd1 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -63,7 +63,7 @@ class ResolveUrlCommandTest extends TestCase public function wrongShortCodeFormatOutputsErrorMessage(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new InvalidShortCodeException()) + $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new ShortUrlNotFoundException()) ->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 9bacaf39..5bf195f1 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -72,7 +72,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface } return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam)); - } catch (InvalidShortCodeException | EntityDoesNotExistException $e) { + } catch (ShortUrlNotFoundException | EntityDoesNotExistException $e) { $this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]); return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/Action/PreviewAction.php b/module/Core/src/Action/PreviewAction.php index 4ac2a50c..9ba0eaf5 100644 --- a/module/Core/src/Action/PreviewAction.php +++ b/module/Core/src/Action/PreviewAction.php @@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\ResponseUtilsTrait; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\PreviewGenerator\Exception\PreviewGenerationException; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGeneratorInterface; @@ -56,7 +56,7 @@ class PreviewAction implements MiddlewareInterface $url = $this->urlShortener->shortCodeToUrl($shortCode); $imagePath = $this->previewGenerator->generatePreview($url->getLongUrl()); return $this->generateImageResponse($imagePath); - } catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) { + } catch (ShortUrlNotFoundException | EntityDoesNotExistException | PreviewGenerationException $e) { $this->logger->warning('An error occurred while generating preview image. {e}', ['e' => $e]); return $handler->handle($request); } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index a1fdae5b..1c5c08df 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Zend\Expressive\Router\Exception\RuntimeException; use Zend\Expressive\Router\RouterInterface; @@ -60,7 +60,7 @@ class QrCodeAction implements MiddlewareInterface try { $this->urlShortener->shortCodeToUrl($shortCode, $domain); - } catch (InvalidShortCodeException | EntityDoesNotExistException $e) { + } catch (ShortUrlNotFoundException | EntityDoesNotExistException $e) { $this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]); return $handler->handle($request); } diff --git a/module/Core/src/Exception/DomainException.php b/module/Core/src/Exception/DomainException.php new file mode 100644 index 00000000..63134952 --- /dev/null +++ b/module/Core/src/Exception/DomainException.php @@ -0,0 +1,11 @@ + $shortCode, ]); if ($shortUrl === null) { - throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); + throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode); } return $shortUrl; diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 02977186..7719cafe 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; @@ -45,7 +45,7 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @param string[] $tags - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl { @@ -57,7 +57,7 @@ class ShortUrlService implements ShortUrlServiceInterface } /** - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl { diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index e519f7c9..cb2c7dca 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Zend\Paginator\Paginator; @@ -20,12 +20,12 @@ interface ShortUrlServiceInterface /** * @param string[] $tags - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl; /** - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl; } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index 836c16a9..612ad4ee 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -9,7 +9,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; @@ -51,14 +51,14 @@ class VisitsTracker implements VisitsTrackerInterface * Returns the visits on certain short code * * @return Visit[]|Paginator - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function info(string $shortCode, VisitsParams $params): Paginator { /** @var ORM\EntityRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); if ($repo->count(['shortCode' => $shortCode]) < 1) { - throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); + throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode); } /** @var VisitRepository $repo */ diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index d3934992..2786d23b 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Zend\Paginator\Paginator; @@ -21,7 +21,7 @@ interface VisitsTrackerInterface * Returns the visits on certain short code * * @return Visit[]|Paginator - * @throws InvalidShortCodeException + * @throws ShortUrlNotFoundException */ public function info(string $shortCode, VisitsParams $params): Paginator; } diff --git a/module/Core/test/Action/PreviewActionTest.php b/module/Core/test/Action/PreviewActionTest.php index bb52ec0e..909d70b7 100644 --- a/module/Core/test/Action/PreviewActionTest.php +++ b/module/Core/test/Action/PreviewActionTest.php @@ -12,7 +12,7 @@ use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\PreviewAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; use Zend\Diactoros\Response; @@ -74,7 +74,7 @@ class PreviewActionTest extends TestCase public function invalidShortCodeExceptionFallsBackToNextMiddleware(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); $process = $delegate->handle(Argument::any())->willReturn(new Response()); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index ddb062dd..24fe8c4b 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequest; @@ -53,7 +53,7 @@ class QrCodeActionTest extends TestCase public function anInvalidShortCodeWillReturnNotFoundResponse(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(InvalidShortCodeException::class) + $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); $process = $delegate->handle(Argument::any())->willReturn(new Response()); diff --git a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php b/module/Core/test/Exception/InvalidShortCodeExceptionTest.php index 4639fda3..c1815db4 100644 --- a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php +++ b/module/Core/test/Exception/InvalidShortCodeExceptionTest.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; class InvalidShortCodeExceptionTest extends TestCase { /** @test */ public function properlyCreatesExceptionFromNotFoundShortCode(): void { - $e = InvalidShortCodeException::fromNotFoundShortCode('abc123'); + $e = ShortUrlNotFoundException::fromNotFoundShortCode('abc123'); $this->assertEquals('No URL found for short code "abc123"', $e->getMessage()); } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 07628f7c..3dae00a7 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -12,7 +12,7 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrlService; @@ -62,7 +62,7 @@ class ShortUrlServiceTest extends TestCase ->shouldBeCalledOnce(); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $this->expectException(InvalidShortCodeException::class); + $this->expectException(ShortUrlNotFoundException::class); $this->service->setTagsByShortCode($shortCode); } diff --git a/module/Rest/src/Action/Visit/GetVisitsAction.php b/module/Rest/src/Action/Visit/GetVisitsAction.php index ed614ae7..68d50533 100644 --- a/module/Rest/src/Action/Visit/GetVisitsAction.php +++ b/module/Rest/src/Action/Visit/GetVisitsAction.php @@ -8,7 +8,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -43,7 +43,7 @@ class GetVisitsAction extends AbstractRestAction return new JsonResponse([ 'visits' => $this->serializePaginator($visits), ]); - } catch (InvalidShortCodeException $e) { + } catch (ShortUrlNotFoundException $e) { $this->logger->warning('Provided nonexistent short code {e}', ['e' => $e]); return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, // FIXME Wrong code. Use correct one in "type" diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 71c65e88..13488b01 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -6,14 +6,14 @@ namespace Shlinkio\Shlink\Rest\Util; use Shlinkio\Shlink\Common\Exception as Common; use Shlinkio\Shlink\Core\Exception as Core; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Rest\Exception as Rest; use Throwable; class RestUtils { /** @deprecated */ - public const INVALID_SHORTCODE_ERROR = InvalidShortCodeException::TYPE; + public const INVALID_SHORTCODE_ERROR = ShortUrlNotFoundException::TYPE; // FIXME Should be INVALID_SHORT_URL_DELETION public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION'; public const INVALID_URL_ERROR = 'INVALID_URL'; @@ -30,7 +30,7 @@ class RestUtils public static function getRestErrorCodeFromException(Throwable $e): string { switch (true) { - case $e instanceof Core\InvalidShortCodeException: + case $e instanceof Core\ShortUrlNotFoundException: return self::INVALID_SHORTCODE_ERROR; case $e instanceof Core\InvalidUrlException: return self::INVALID_URL_ERROR; diff --git a/module/Rest/test/Action/Visit/GetVisitsActionTest.php b/module/Rest/test/Action/Visit/GetVisitsActionTest.php index 789469ca..cb38dd85 100644 --- a/module/Rest/test/Action/Visit/GetVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GetVisitsActionTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction; @@ -47,7 +47,7 @@ class GetVisitsActionTest extends TestCase { $shortCode = 'abc123'; $this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willThrow( - InvalidShortCodeException::class + ShortUrlNotFoundException::class )->shouldBeCalledOnce(); $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); diff --git a/module/Rest/test/Util/RestUtilsTest.php b/module/Rest/test/Util/RestUtilsTest.php index c958594b..d8a45181 100644 --- a/module/Rest/test/Util/RestUtilsTest.php +++ b/module/Rest/test/Util/RestUtilsTest.php @@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Util; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; -use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\Rest\Exception\AuthenticationException; @@ -19,7 +19,7 @@ class RestUtilsTest extends TestCase { $this->assertEquals( RestUtils::INVALID_SHORTCODE_ERROR, - RestUtils::getRestErrorCodeFromException(new InvalidShortCodeException()) + RestUtils::getRestErrorCodeFromException(new ShortUrlNotFoundException()) ); $this->assertEquals( RestUtils::INVALID_URL_ERROR, From 0d7d53ab5babcb996faba1a339bb98768e9e97a0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 23:24:53 +0100 Subject: [PATCH 022/100] Converted InvalidUrlException into a problem details exception --- .../src/Exception/InvalidUrlException.php | 21 ++++++++++++++++--- .../Exception/InvalidUrlExceptionTest.php | 5 +++-- .../ShortUrl/AbstractCreateShortUrlAction.php | 7 ------- module/Rest/src/Util/RestUtils.php | 3 ++- .../ShortUrl/CreateShortUrlActionTest.php | 16 -------------- 5 files changed, 23 insertions(+), 29 deletions(-) diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php index 5cd79af5..32758b4e 100644 --- a/module/Core/src/Exception/InvalidUrlException.php +++ b/module/Core/src/Exception/InvalidUrlException.php @@ -4,15 +4,30 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; +use Fig\Http\Message\StatusCodeInterface; use Throwable; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function sprintf; -class InvalidUrlException extends RuntimeException +class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + private const TITLE = 'Invalid URL'; + public const TYPE = 'INVALID_URL'; + public static function fromUrl(string $url, ?Throwable $previous = null): self { - $code = $previous !== null ? $previous->getCode() : -1; - return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous); + $status = StatusCodeInterface::STATUS_BAD_REQUEST; + $e = new self(sprintf('Provided URL %s is invalid. Try with a different one.', $url), $status, $previous); + + $e->detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = $status; + + return $e; } } diff --git a/module/Core/test/Exception/InvalidUrlExceptionTest.php b/module/Core/test/Exception/InvalidUrlExceptionTest.php index a5d19341..1b7de449 100644 --- a/module/Core/test/Exception/InvalidUrlExceptionTest.php +++ b/module/Core/test/Exception/InvalidUrlExceptionTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Exception; use Exception; +use Fig\Http\Message\StatusCodeInterface; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Throwable; @@ -19,8 +20,8 @@ class InvalidUrlExceptionTest extends TestCase { $e = InvalidUrlException::fromUrl('http://the_url.com', $prev); - $this->assertEquals('Provided URL "http://the_url.com" is not an existing and valid URL', $e->getMessage()); - $this->assertEquals($prev !== null ? $prev->getCode() : -1, $e->getCode()); + $this->assertEquals('Provided URL http://the_url.com is invalid. Try with a different one.', $e->getMessage()); + $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); $this->assertEquals($prev, $e->getPrevious()); } diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index bc0d50ef..d627b427 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; @@ -60,12 +59,6 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction $transformer = new ShortUrlDataTransformer($this->domainConfig); return new JsonResponse($transformer->transform($shortUrl)); - } catch (InvalidUrlException $e) { - $this->logger->warning('Provided Invalid URL. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided URL %s is invalid. Try with a different one.', $longUrl), - ], self::STATUS_BAD_REQUEST); } catch (NonUniqueSlugException $e) { $customSlug = $shortUrlMeta->getCustomSlug(); $this->logger->warning('Provided non-unique slug. {e}', ['e' => $e]); diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 13488b01..279d7fca 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -16,7 +16,8 @@ class RestUtils public const INVALID_SHORTCODE_ERROR = ShortUrlNotFoundException::TYPE; // FIXME Should be INVALID_SHORT_URL_DELETION public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION'; - public const INVALID_URL_ERROR = 'INVALID_URL'; + /** @deprecated */ + public const INVALID_URL_ERROR = Core\InvalidUrlException::TYPE; public const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; public const INVALID_SLUG_ERROR = 'INVALID_SLUG'; public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 732cbc25..1cc5c889 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; @@ -61,21 +60,6 @@ class CreateShortUrlActionTest extends TestCase $this->assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0); } - /** @test */ - public function anInvalidUrlReturnsError(): void - { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) - ->willThrow(InvalidUrlException::class) - ->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withParsedBody([ - 'longUrl' => 'http://www.domain.com/foo/bar', - ]); - $response = $this->action->handle($request); - $this->assertEquals(400, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_URL_ERROR) > 0); - } - /** * @test * @dataProvider provideInvalidDomains From c1eee2246bbe222c71ab6fde17dfa372f93a6192 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 23:32:37 +0100 Subject: [PATCH 023/100] Converted NonUniqueSlugException into a problem details exception --- .../src/Exception/NonUniqueSlugException.php | 20 ++++++++++++++++-- .../Exception/NonUniqueSlugExceptionTest.php | 4 ++-- .../ShortUrl/AbstractCreateShortUrlAction.php | 19 ++++------------- module/Rest/src/Util/RestUtils.php | 3 ++- .../ShortUrl/CreateShortUrlActionTest.php | 21 ------------------- 5 files changed, 26 insertions(+), 41 deletions(-) diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index fb7e4503..9e22d354 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -4,10 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; +use Fig\Http\Message\StatusCodeInterface; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; + use function sprintf; -class NonUniqueSlugException extends InvalidArgumentException +class NonUniqueSlugException extends InvalidArgumentException implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + private const TITLE = 'Invalid custom slug'; + public const TYPE = 'INVALID_SLUG'; + public static function fromSlug(string $slug, ?string $domain): self { $suffix = ''; @@ -15,6 +24,13 @@ class NonUniqueSlugException extends InvalidArgumentException $suffix = sprintf(' for domain "%s"', $domain); } - return new self(sprintf('Provided slug "%s" is not unique%s.', $slug, $suffix)); + $e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix)); + + $e->detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + + return $e; } } diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index 71c4a276..d2008621 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -22,12 +22,12 @@ class NonUniqueSlugExceptionTest extends TestCase public function provideMessages(): iterable { yield 'without domain' => [ - 'Provided slug "foo" is not unique.', + 'Provided slug "foo" is already in use.', 'foo', null, ]; yield 'with domain' => [ - 'Provided slug "baz" is not unique for domain "bar".', + 'Provided slug "baz" is already in use for domain "bar".', 'baz', 'bar', ]; diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index d627b427..38daf905 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -16,8 +15,6 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; -use function sprintf; - abstract class AbstractCreateShortUrlAction extends AbstractRestAction { /** @var UrlShortenerInterface */ @@ -52,21 +49,13 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction } $longUrl = $shortUrlData->getLongUrl(); + $tags = $shortUrlData->getTags(); $shortUrlMeta = $shortUrlData->getMeta(); - try { - $shortUrl = $this->urlShortener->urlToShortCode($longUrl, $shortUrlData->getTags(), $shortUrlMeta); - $transformer = new ShortUrlDataTransformer($this->domainConfig); + $shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, $shortUrlMeta); + $transformer = new ShortUrlDataTransformer($this->domainConfig); - return new JsonResponse($transformer->transform($shortUrl)); - } catch (NonUniqueSlugException $e) { - $customSlug = $shortUrlMeta->getCustomSlug(); - $this->logger->warning('Provided non-unique slug. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided slug %s is already in use. Try with a different one.', $customSlug), - ], self::STATUS_BAD_REQUEST); - } + return new JsonResponse($transformer->transform($shortUrl)); } /** diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 279d7fca..24398eb9 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -19,7 +19,8 @@ class RestUtils /** @deprecated */ public const INVALID_URL_ERROR = Core\InvalidUrlException::TYPE; public const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; - public const INVALID_SLUG_ERROR = 'INVALID_SLUG'; + /** @deprecated */ + public const INVALID_SLUG_ERROR = Core\NonUniqueSlugException::TYPE; public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; public const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; public const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION'; diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 1cc5c889..c4f8970d 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -8,8 +8,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; -use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; use Shlinkio\Shlink\Rest\Util\RestUtils; @@ -88,23 +86,4 @@ class CreateShortUrlActionTest extends TestCase yield ['127.0.0.1']; yield ['???/&%$&']; } - - /** @test */ - public function nonUniqueSlugReturnsError(): void - { - $this->urlShortener->urlToShortCode( - Argument::type(Uri::class), - Argument::type('array'), - ShortUrlMeta::createFromRawData(['customSlug' => 'foo']), - Argument::cetera() - )->willThrow(NonUniqueSlugException::class)->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withParsedBody([ - 'longUrl' => 'http://www.domain.com/foo/bar', - 'customSlug' => 'foo', - ]); - $response = $this->action->handle($request); - $this->assertEquals(400, $response->getStatusCode()); - $this->assertStringContainsString(RestUtils::INVALID_SLUG_ERROR, (string) $response->getBody()); - } } From 32b3c72bdff6e91e089522e244dcba91a0e1232d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 23:45:40 +0100 Subject: [PATCH 024/100] Converted ValidationException into a problem details exception --- .../src/Exception/ValidationException.php | 23 ++++++++++++++-- .../Exception/ValidationExceptionTest.php | 3 ++- .../ShortUrl/AbstractCreateShortUrlAction.php | 16 +---------- .../Action/ShortUrl/EditShortUrlAction.php | 27 ++----------------- module/Rest/src/Util/RestUtils.php | 6 ++--- .../ShortUrl/CreateShortUrlActionTest.php | 17 +++++------- .../ShortUrl/EditShortUrlActionTest.php | 11 +++----- .../SingleStepCreateShortUrlActionTest.php | 22 +++++---------- 8 files changed, 47 insertions(+), 78 deletions(-) diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 70dfe0d0..510a31b0 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; +use Fig\Http\Message\StatusCodeInterface; use Throwable; use Zend\InputFilter\InputFilterInterface; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function Functional\reduce_left; use function is_array; @@ -14,8 +17,13 @@ use function sprintf; use const PHP_EOL; -class ValidationException extends RuntimeException +class ValidationException extends RuntimeException implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + private const TITLE = 'Invalid data'; + public const TYPE = 'INVALID_ARGUMENT'; + /** @var array */ private $invalidElements; @@ -36,7 +44,18 @@ class ValidationException extends RuntimeException public static function fromArray(array $invalidData, ?Throwable $prev = null): self { - return new self('Provided data is not valid', $invalidData, -1, $prev); + $status = StatusCodeInterface::STATUS_BAD_REQUEST; + $e = new self('Provided data is not valid', $invalidData, $status, $prev); + + $e->detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + $e->additional = [ + 'invalidElements' => $invalidData, + ]; + + return $e; } public function getInvalidElements(): array diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index bd7855e2..eb47caed 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Exception; +use Fig\Http\Message\StatusCodeInterface; use LogicException; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -67,7 +68,7 @@ EOT; $this->assertEquals($invalidData, $e->getInvalidElements()); $this->assertEquals('Provided data is not valid', $e->getMessage()); - $this->assertEquals(-1, $e->getCode()); + $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); $this->assertEquals($prev, $e->getPrevious()); $this->assertStringContainsString($expectedStringRepresentation, (string) $e); $getMessages->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 38daf905..af392067 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -12,7 +12,6 @@ use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; abstract class AbstractCreateShortUrlAction extends AbstractRestAction @@ -32,22 +31,9 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction $this->domainConfig = $domainConfig; } - /** - * @param Request $request - * @return Response - */ public function handle(Request $request): Response { - try { - $shortUrlData = $this->buildShortUrlData($request); - } catch (ValidationException $e) { - $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => $e->getMessage(), - ], self::STATUS_BAD_REQUEST); - } - + $shortUrlData = $this->buildShortUrlData($request); $longUrl = $shortUrlData->getLongUrl(); $tags = $shortUrlData->getTags(); $shortUrlMeta = $shortUrlData->getMeta(); diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 8391db8a..33796f7d 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -7,13 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\JsonResponse; class EditShortUrlAction extends AbstractRestAction { @@ -29,32 +26,12 @@ class EditShortUrlAction extends AbstractRestAction $this->shortUrlService = $shortUrlService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * @param ServerRequestInterface $request - * - * @return ResponseInterface - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { $postData = (array) $request->getParsedBody(); $shortCode = $request->getAttribute('shortCode', ''); - try { - $this->shortUrlService->updateMetadataByShortCode( - $shortCode, - ShortUrlMeta::createFromRawData($postData) - ); - return new EmptyResponse(); - } catch (Exception\ValidationException $e) { - $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => 'Provided data is invalid.', - ], self::STATUS_BAD_REQUEST); - } + $this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::createFromRawData($postData)); + return new EmptyResponse(); } } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 24398eb9..096db51a 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -6,19 +6,19 @@ namespace Shlinkio\Shlink\Rest\Util; use Shlinkio\Shlink\Common\Exception as Common; use Shlinkio\Shlink\Core\Exception as Core; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Rest\Exception as Rest; use Throwable; class RestUtils { /** @deprecated */ - public const INVALID_SHORTCODE_ERROR = ShortUrlNotFoundException::TYPE; + public const INVALID_SHORTCODE_ERROR = Core\ShortUrlNotFoundException::TYPE; // FIXME Should be INVALID_SHORT_URL_DELETION public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION'; /** @deprecated */ public const INVALID_URL_ERROR = Core\InvalidUrlException::TYPE; - public const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT'; + /** @deprecated */ + public const INVALID_ARGUMENT_ERROR = Core\ValidationException::TYPE; /** @deprecated */ public const INVALID_SLUG_ERROR = Core\NonUniqueSlugException::TYPE; public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index c4f8970d..92a3c2aa 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -8,10 +8,9 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; -use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Uri; @@ -38,8 +37,8 @@ class CreateShortUrlActionTest extends TestCase /** @test */ public function missingLongUrlParamReturnsError(): void { - $response = $this->action->handle(new ServerRequest()); - $this->assertEquals(400, $response->getStatusCode()); + $this->expectException(ValidationException::class); + $this->action->handle(new ServerRequest()); } /** @test */ @@ -71,13 +70,11 @@ class CreateShortUrlActionTest extends TestCase 'longUrl' => 'http://www.domain.com/foo/bar', 'domain' => $domain, ]); - /** @var JsonResponse $response */ - $response = $this->action->handle($request); - $payload = $response->getPayload(); - $this->assertEquals(400, $response->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $payload['error']); - $urlToShortCode->shouldNotHaveBeenCalled(); + $this->expectException(ValidationException::class); + $urlToShortCode->shouldNotBeCalled(); + + $this->action->handle($request); } public function provideInvalidDomains(): iterable diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 55fcc2d7..63aa1ffb 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; 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\Rest\Action\ShortUrl\EditShortUrlAction; use Shlinkio\Shlink\Rest\Util\RestUtils; @@ -28,19 +29,15 @@ class EditShortUrlActionTest extends TestCase } /** @test */ - public function invalidDataReturnsError(): void + public function invalidDataThrowsError(): void { $request = (new ServerRequest())->withParsedBody([ 'maxVisits' => 'invalid', ]); - /** @var JsonResponse $resp */ - $resp = $this->action->handle($request); - $payload = $resp->getPayload(); + $this->expectException(ValidationException::class); - $this->assertEquals(400, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $payload['error']); - $this->assertEquals('Provided data is invalid.', $payload['message']); + $this->action->handle($request); } /** @test */ diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 8bebc6c5..b0d8c65d 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -10,11 +10,11 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\UriInterface; 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; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\ServerRequest; class SingleStepCreateShortUrlActionTest extends TestCase @@ -47,14 +47,10 @@ class SingleStepCreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); $findApiKey = $this->apiKeyService->check('abc123')->willReturn(false); - /** @var JsonResponse $resp */ - $resp = $this->action->handle($request); - $payload = $resp->getPayload(); + $this->expectException(ValidationException::class); + $findApiKey->shouldBeCalledOnce(); - $this->assertEquals(400, $resp->getStatusCode()); - $this->assertEquals('INVALID_ARGUMENT', $payload['error']); - $this->assertEquals('Provided data is not valid', $payload['message']); - $findApiKey->shouldHaveBeenCalled(); + $this->action->handle($request); } /** @test */ @@ -63,14 +59,10 @@ class SingleStepCreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); - /** @var JsonResponse $resp */ - $resp = $this->action->handle($request); - $payload = $resp->getPayload(); + $this->expectException(ValidationException::class); + $findApiKey->shouldBeCalledOnce(); - $this->assertEquals(400, $resp->getStatusCode()); - $this->assertEquals('INVALID_ARGUMENT', $payload['error']); - $this->assertEquals('Provided data is not valid', $payload['message']); - $findApiKey->shouldHaveBeenCalled(); + $this->action->handle($request); } /** @test */ From 310032e3035dc90c664955a22e1058863eb38d46 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Nov 2019 23:56:02 +0100 Subject: [PATCH 025/100] Converted DeleteShortUrlException into a problem details exception --- .../src/Exception/DeleteShortUrlException.php | 20 +++++++++++-- .../Action/ShortUrl/DeleteShortUrlAction.php | 24 ++------------- module/Rest/src/Util/RestUtils.php | 4 +-- .../Action/DeleteShortUrlActionTest.php | 4 +-- .../ShortUrl/DeleteShortUrlActionTest.php | 30 ------------------- 5 files changed, 24 insertions(+), 58 deletions(-) diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index c1fd6dd7..037fe942 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -4,12 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; +use Fig\Http\Message\StatusCodeInterface; use Throwable; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function sprintf; -class DeleteShortUrlException extends RuntimeException +class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + + private const TITLE = 'Cannot delete short URL'; + public const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION + /** @var int */ private $visitsThreshold; @@ -21,11 +29,19 @@ class DeleteShortUrlException extends RuntimeException public static function fromVisitsThreshold(int $threshold, string $shortCode): self { - return new self($threshold, sprintf( + $e = new self($threshold, sprintf( 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', $shortCode, $threshold )); + + $e->detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY; + $e->additional = ['threshold' => $threshold]; + + return $e; } public function getVisitsThreshold(): int diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index f6873025..ba39ec82 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -7,14 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\JsonResponse; - -use function sprintf; class DeleteShortUrlAction extends AbstractRestAction { @@ -30,25 +25,10 @@ class DeleteShortUrlAction extends AbstractRestAction $this->deleteShortUrlService = $deleteShortUrlService; } - /** - * Handle the request and return a response. - */ public function handle(ServerRequestInterface $request): ResponseInterface { $shortCode = $request->getAttribute('shortCode', ''); - - try { - $this->deleteShortUrlService->deleteByShortCode($shortCode); - return new EmptyResponse(); - } catch (Exception\DeleteShortUrlException $e) { - $this->logger->warning('Provided data is invalid. {e}', ['e' => $e]); - $messagePlaceholder = - 'It is not possible to delete URL with short code "%s" because it has reached more than "%s" visits.'; - - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf($messagePlaceholder, $shortCode, $e->getVisitsThreshold()), - ], self::STATUS_BAD_REQUEST); - } + $this->deleteShortUrlService->deleteByShortCode($shortCode); + return new EmptyResponse(); } } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 096db51a..97ca0d0b 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -13,8 +13,8 @@ class RestUtils { /** @deprecated */ public const INVALID_SHORTCODE_ERROR = Core\ShortUrlNotFoundException::TYPE; - // FIXME Should be INVALID_SHORT_URL_DELETION - public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION'; + /** @deprecated */ + public const INVALID_SHORTCODE_DELETION_ERROR = Core\DeleteShortUrlException::TYPE; /** @deprecated */ public const INVALID_URL_ERROR = Core\InvalidUrlException::TYPE; /** @deprecated */ diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index c90b7818..042b8e90 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -20,7 +20,7 @@ class DeleteShortUrlActionTest extends ApiTestCase } /** @test */ - public function badRequestIsReturnedWhenTryingToDeleteUrlWithTooManyVisits(): void + public function unprocessableEntityIsReturnedWhenTryingToDeleteUrlWithTooManyVisits(): void { // Generate visits first for ($i = 0; $i < 20; $i++) { @@ -30,7 +30,7 @@ class DeleteShortUrlActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123'); ['error' => $error] = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode()); $this->assertEquals(RestUtils::INVALID_SHORTCODE_DELETION_ERROR, $error); } } diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 2e77d10a..782181ce 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -7,12 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\DeleteShortUrlAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; -use Throwable; -use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\ServerRequest; class DeleteShortUrlActionTest extends TestCase @@ -39,30 +35,4 @@ class DeleteShortUrlActionTest extends TestCase $this->assertEquals(204, $resp->getStatusCode()); $deleteByShortCode->shouldHaveBeenCalledOnce(); } - - /** - * @test - * @dataProvider provideExceptions - */ - public function returnsErrorResponseInCaseOfException(Throwable $e, string $error, int $statusCode): void - { - $deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->willThrow($e); - - /** @var JsonResponse $resp */ - $resp = $this->action->handle(new ServerRequest()); - $payload = $resp->getPayload(); - - $this->assertEquals($statusCode, $resp->getStatusCode()); - $this->assertEquals($error, $payload['error']); - $deleteByShortCode->shouldHaveBeenCalledOnce(); - } - - public function provideExceptions(): iterable - { - yield 'bad request' => [ - new Exception\DeleteShortUrlException(5), - RestUtils::INVALID_SHORTCODE_DELETION_ERROR, - 400, - ]; - } } From 0c5eec7e95b7a7d6a817dd73316343f2d7281d47 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 25 Nov 2019 18:54:25 +0100 Subject: [PATCH 026/100] Replaced the use of EntityDoesNotExistException by ShorturlNotFoundException where applicable --- .../Command/ShortUrl/ResolveUrlCommand.php | 4 -- .../ShortUrl/ResolveUrlCommandTest.php | 19 ++-------- .../src/Action/AbstractTrackingAction.php | 3 +- module/Core/src/Action/PreviewAction.php | 3 +- module/Core/src/Action/QrCodeAction.php | 3 +- .../src/Exception/NonUniqueSlugException.php | 6 +-- .../Exception/ShortUrlNotFoundException.php | 5 ++- module/Core/src/Service/UrlShortener.php | 9 ++--- .../src/Service/UrlShortenerInterface.php | 6 +-- module/Core/test/Action/PreviewActionTest.php | 14 ------- module/Core/test/Action/QrCodeActionTest.php | 3 +- .../Core/test/Action/RedirectActionTest.php | 4 +- .../InvalidShortCodeExceptionTest.php | 19 ---------- .../ShortUrlNotFoundExceptionTest.php | 38 +++++++++++++++++++ .../Action/ShortUrl/ResolveShortUrlAction.php | 16 +------- .../Action/ResolveShortUrlActionTest.php | 2 +- .../ShortUrl/ResolveShortUrlActionTest.php | 15 -------- 17 files changed, 60 insertions(+), 109 deletions(-) delete mode 100644 module/Core/test/Exception/InvalidShortCodeExceptionTest.php create mode 100644 module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index b758ddd4..28564369 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; @@ -66,9 +65,6 @@ class ResolveUrlCommand extends Command $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return ExitCodes::EXIT_SUCCESS; } catch (ShortUrlNotFoundException $e) { - $io->error(sprintf('Provided short code "%s" has an invalid format.', $shortCode)); - return ExitCodes::EXIT_FAILURE; - } catch (EntityDoesNotExistException $e) { $io->error(sprintf('Provided short code "%s" could not be found.', $shortCode)); return ExitCodes::EXIT_FAILURE; } diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 9e86bbd1..23b4ec28 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -8,12 +8,13 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use function sprintf; + use const PHP_EOL; class ResolveUrlCommandTest extends TestCase @@ -51,23 +52,11 @@ class ResolveUrlCommandTest extends TestCase public function incorrectShortCodeOutputsErrorMessage(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class) + $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output); - } - - /** @test */ - public function wrongShortCodeFormatOutputsErrorMessage(): void - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new ShortUrlNotFoundException()) - ->shouldBeCalledOnce(); - - $this->commandTester->execute(['shortCode' => $shortCode]); - $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output); + $this->assertStringContainsString(sprintf('Provided short code "%s" could not be found', $shortCode), $output); } } diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 5bf195f1..ff8d91f2 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -11,7 +11,6 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; @@ -72,7 +71,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface } return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam)); - } catch (ShortUrlNotFoundException | EntityDoesNotExistException $e) { + } catch (ShortUrlNotFoundException $e) { $this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]); return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/Action/PreviewAction.php b/module/Core/src/Action/PreviewAction.php index 9ba0eaf5..d243f12c 100644 --- a/module/Core/src/Action/PreviewAction.php +++ b/module/Core/src/Action/PreviewAction.php @@ -11,7 +11,6 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\ResponseUtilsTrait; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\PreviewGenerator\Exception\PreviewGenerationException; @@ -56,7 +55,7 @@ class PreviewAction implements MiddlewareInterface $url = $this->urlShortener->shortCodeToUrl($shortCode); $imagePath = $this->previewGenerator->generatePreview($url->getLongUrl()); return $this->generateImageResponse($imagePath); - } catch (ShortUrlNotFoundException | EntityDoesNotExistException | PreviewGenerationException $e) { + } catch (ShortUrlNotFoundException | PreviewGenerationException $e) { $this->logger->warning('An error occurred while generating preview image. {e}', ['e' => $e]); return $handler->handle($request); } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 1c5c08df..3cdee70e 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -12,7 +12,6 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Zend\Expressive\Router\Exception\RuntimeException; @@ -60,7 +59,7 @@ class QrCodeAction implements MiddlewareInterface try { $this->urlShortener->shortCodeToUrl($shortCode, $domain); - } catch (ShortUrlNotFoundException | EntityDoesNotExistException $e) { + } catch (ShortUrlNotFoundException $e) { $this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]); return $handler->handle($request); } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 9e22d354..74e03f06 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -19,11 +19,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem public static function fromSlug(string $slug, ?string $domain): self { - $suffix = ''; - if ($domain !== null) { - $suffix = sprintf(' for domain "%s"', $domain); - } - + $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix)); $e->detail = $e->getMessage(); diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index d8682047..e07624c7 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -17,9 +17,10 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail private const TITLE = 'Short URL not found'; public const TYPE = 'INVALID_SHORTCODE'; - public static function fromNotFoundShortCode(string $shortCode): self + public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self { - $e = new self(sprintf('No URL found for short code "%s"', $shortCode)); + $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); + $e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix)); $e->detail = $e->getMessage(); $e->title = self::TITLE; diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 7d71ca7a..0e4062ec 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -8,9 +8,9 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; @@ -129,7 +129,7 @@ class UrlShortener implements UrlShortenerInterface } /** - * @throws EntityDoesNotExistException + * @throws ShortUrlNotFoundException */ public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl { @@ -137,10 +137,7 @@ class UrlShortener implements UrlShortenerInterface $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain); if ($shortUrl === null) { - throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [ - 'shortCode' => $shortCode, - 'domain' => $domain, - ]); + throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain); } return $shortUrl; diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 9d6b291b..ee9cc343 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -6,10 +6,9 @@ namespace Shlinkio\Shlink\Core\Service; use Psr\Http\Message\UriInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; -use Shlinkio\Shlink\Core\Exception\RuntimeException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; interface UrlShortenerInterface @@ -18,12 +17,11 @@ interface UrlShortenerInterface * @param string[] $tags * @throws NonUniqueSlugException * @throws InvalidUrlException - * @throws RuntimeException */ public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl; /** - * @throws EntityDoesNotExistException + * @throws ShortUrlNotFoundException */ public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl; } diff --git a/module/Core/test/Action/PreviewActionTest.php b/module/Core/test/Action/PreviewActionTest.php index 909d70b7..e2cb089c 100644 --- a/module/Core/test/Action/PreviewActionTest.php +++ b/module/Core/test/Action/PreviewActionTest.php @@ -11,7 +11,6 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\PreviewAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; @@ -38,19 +37,6 @@ class PreviewActionTest extends TestCase $this->action = new PreviewAction($this->previewGenerator->reveal(), $this->urlShortener->reveal()); } - /** @test */ - public function invalidShortCodeFallsBackToNextMiddleware(): void - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) - ->shouldBeCalledOnce(); - $delegate = $this->prophesize(RequestHandlerInterface::class); - $delegate->handle(Argument::cetera())->shouldBeCalledOnce() - ->willReturn(new Response()); - - $this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal()); - } - /** @test */ public function correctShortCodeReturnsImageResponse(): void { diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 24fe8c4b..6327ad69 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -11,7 +11,6 @@ use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Zend\Diactoros\Response; @@ -39,7 +38,7 @@ class QrCodeActionTest extends TestCase public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class) + $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); $process = $delegate->handle(Argument::any())->willReturn(new Response()); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index a65927eb..55342cb5 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\VisitsTracker; @@ -76,7 +76,7 @@ class RedirectActionTest extends TestCase public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class) + $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled(); diff --git a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php b/module/Core/test/Exception/InvalidShortCodeExceptionTest.php deleted file mode 100644 index c1815db4..00000000 --- a/module/Core/test/Exception/InvalidShortCodeExceptionTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertEquals('No URL found for short code "abc123"', $e->getMessage()); - } -} diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php new file mode 100644 index 00000000..6f0d58f4 --- /dev/null +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -0,0 +1,38 @@ +assertEquals($expectedMessage, $e->getMessage()); + } + + public function provideMessages(): iterable + { + yield 'without domain' => [ + 'No URL found with short code "abc123"', + 'abc123', + null, + ]; + yield 'with domain' => [ + 'No URL found with short code "bar" for domain "foo"', + 'bar', + 'foo', + ]; + } +} diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 9599013a..e041f4dc 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -8,15 +8,11 @@ use InvalidArgumentException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; -use function sprintf; - class ResolveShortUrlAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}'; @@ -48,15 +44,7 @@ class ResolveShortUrlAction extends AbstractRestAction $domain = $request->getQueryParams()['domain'] ?? null; $transformer = new ShortUrlDataTransformer($this->domainConfig); - try { - $url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); - return new JsonResponse($transformer->transform($url)); - } catch (EntityDoesNotExistException $e) { - $this->logger->warning('Provided short code couldn\'t be found. {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, // FIXME Not correct. Use correct value on "type" - 'message' => sprintf('No URL found for short code "%s"', $shortCode), - ], self::STATUS_NOT_FOUND); - } + $url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); + return new JsonResponse($transformer->transform($url)); } } diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index 117ae5a9..4065517e 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -16,6 +16,6 @@ class ResolveShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); } } diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index aa852de5..b77a6e45 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\ServerRequest; use function strpos; @@ -28,19 +26,6 @@ class ResolveShortUrlActionTest extends TestCase $this->action = new ResolveShortUrlAction($this->urlShortener->reveal(), []); } - /** @test */ - public function incorrectShortCodeReturnsError(): void - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class) - ->shouldBeCalledOnce(); - - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $response = $this->action->handle($request); - $this->assertEquals(404, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_ARGUMENT_ERROR) > 0); - } - /** @test */ public function correctShortCodeReturnsSuccess(): void { From a28ef1f1769ab28ed976bbdaad575fd097daa692 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 25 Nov 2019 19:15:46 +0100 Subject: [PATCH 027/100] Converted EntityDoesNotExistException into a problem details exception renamed as TagNotFoundException --- docs/swagger/paths/v1_short-urls_shorten.json | 4 +-- .../CLI/src/Command/Tag/RenameTagCommand.php | 4 +-- .../test/Command/Tag/RenameTagCommandTest.php | 4 +-- .../Exception/EntityDoesNotExistException.php | 30 ------------------ .../src/Exception/TagNotFoundException.php | 31 +++++++++++++++++++ module/Core/src/Service/Tag/TagService.php | 19 ++++-------- .../src/Service/Tag/TagServiceInterface.php | 12 ++----- .../Core/test/Service/Tag/TagServiceTest.php | 4 +-- .../Rest/src/Action/Tag/UpdateTagAction.php | 26 +++++----------- .../Rest/src/Action/Visit/GetVisitsAction.php | 21 +++---------- .../src/Exception/AuthenticationException.php | 1 + module/Rest/src/Util/RestUtils.php | 5 ++- .../test-api/Action/GetVisitsActionTest.php | 2 +- .../ShortUrl/EditShortUrlActionTest.php | 2 -- .../test/Action/Tag/UpdateTagActionTest.php | 22 +++---------- .../test/Action/Visit/GetVisitsActionTest.php | 13 -------- module/Rest/test/Util/RestUtilsTest.php | 2 +- 17 files changed, 70 insertions(+), 132 deletions(-) delete mode 100644 module/Core/src/Exception/EntityDoesNotExistException.php create mode 100644 module/Core/src/Exception/TagNotFoundException.php diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 803d77d5..d0c3c4c7 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -112,10 +112,10 @@ }, "examples": { "application/json": { - "error": "UNKNOWN_ERROR", + "error": "INTERNAL_SERVER_ERROR", "message": "Unexpected error occurred" }, - "text/plain": "UNKNOWN_ERROR" + "text/plain": "INTERNAL_SERVER_ERROR" } } } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 3e9f021b..a7002f60 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -47,7 +47,7 @@ class RenameTagCommand extends Command $this->tagService->renameTag($oldName, $newName); $io->success('Tag properly renamed.'); return ExitCodes::EXIT_SUCCESS; - } catch (EntityDoesNotExistException $e) { + } catch (TagNotFoundException $e) { $io->error(sprintf('A tag with name "%s" was not found', $oldName)); return ExitCodes::EXIT_FAILURE; } diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 228d854d..4fc2aaad 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -38,7 +38,7 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class); + $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::class); $this->commandTester->execute([ 'oldName' => $oldName, diff --git a/module/Core/src/Exception/EntityDoesNotExistException.php b/module/Core/src/Exception/EntityDoesNotExistException.php deleted file mode 100644 index 9214aa47..00000000 --- a/module/Core/src/Exception/EntityDoesNotExistException.php +++ /dev/null @@ -1,30 +0,0 @@ - $value) { - $result[] = sprintf('"%s" => "%s"', $key, $value); - } - - return implode(', ', $result); - } -} diff --git a/module/Core/src/Exception/TagNotFoundException.php b/module/Core/src/Exception/TagNotFoundException.php new file mode 100644 index 00000000..6ff36bb2 --- /dev/null +++ b/module/Core/src/Exception/TagNotFoundException.php @@ -0,0 +1,31 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + + return $e; + } +} diff --git a/module/Core/src/Service/Tag/TagService.php b/module/Core/src/Service/Tag/TagService.php index d5ac562e..40bdbfcd 100644 --- a/module/Core/src/Service/Tag/TagService.php +++ b/module/Core/src/Service/Tag/TagService.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag; use Doctrine\Common\Collections\Collection; use Doctrine\ORM; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Util\TagManagerTrait; @@ -35,8 +35,7 @@ class TagService implements TagServiceInterface } /** - * @param array $tagNames - * @return void + * @param string[] $tagNames */ public function deleteTags(array $tagNames): void { @@ -60,23 +59,17 @@ class TagService implements TagServiceInterface } /** - * @param string $oldName - * @param string $newName - * @return Tag - * @throws EntityDoesNotExistException - * @throws ORM\OptimisticLockException + * @throws TagNotFoundException */ - public function renameTag($oldName, $newName): Tag + public function renameTag(string $oldName, string $newName): Tag { - $criteria = ['name' => $oldName]; /** @var Tag|null $tag */ - $tag = $this->em->getRepository(Tag::class)->findOneBy($criteria); + $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $oldName]); if ($tag === null) { - throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria); + throw TagNotFoundException::fromTag($oldName); } $tag->rename($newName); - $this->em->flush(); return $tag; diff --git a/module/Core/src/Service/Tag/TagServiceInterface.php b/module/Core/src/Service/Tag/TagServiceInterface.php index 1eb11112..dec01e31 100644 --- a/module/Core/src/Service/Tag/TagServiceInterface.php +++ b/module/Core/src/Service/Tag/TagServiceInterface.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag; use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; interface TagServiceInterface { @@ -17,23 +17,17 @@ interface TagServiceInterface /** * @param string[] $tagNames - * @return void */ public function deleteTags(array $tagNames): void; /** - * Provided a list of tag names, creates all that do not exist yet - * * @param string[] $tagNames * @return Collection|Tag[] */ public function createTags(array $tagNames): Collection; /** - * @param string $oldName - * @param string $newName - * @return Tag - * @throws EntityDoesNotExistException + * @throws TagNotFoundException */ - public function renameTag($oldName, $newName): Tag; + public function renameTag(string $oldName, string $newName): Tag; } diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 0dd63ff5..0131d9e9 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Service\Tag\TagService; @@ -83,7 +83,7 @@ class TagServiceTest extends TestCase $find->shouldBeCalled(); $getRepo->shouldBeCalled(); - $this->expectException(EntityDoesNotExistException::class); + $this->expectException(TagNotFoundException::class); $this->service->renameTag('foo', 'bar'); } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 64ec7a21..175ed0a0 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,14 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\JsonResponse; - -use function sprintf; class UpdateTagAction extends AbstractRestAction { @@ -43,21 +39,13 @@ class UpdateTagAction extends AbstractRestAction { $body = $request->getParsedBody(); if (! isset($body['oldName'], $body['newName'])) { - return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => - 'You have to provide both \'oldName\' and \'newName\' params in order to properly rename the tag', - ], self::STATUS_BAD_REQUEST); + throw ValidationException::fromArray([ + 'oldName' => 'oldName is required', + 'newName' => 'newName is required', + ]); } - try { - $this->tagService->renameTag($body['oldName'], $body['newName']); - return new EmptyResponse(); - } catch (EntityDoesNotExistException $e) { - return new JsonResponse([ - 'error' => RestUtils::NOT_FOUND_ERROR, - 'message' => sprintf('It was not possible to find a tag with name %s', $body['oldName']), - ], self::STATUS_NOT_FOUND); - } + $this->tagService->renameTag($body['oldName'], $body['newName']); + return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/Visit/GetVisitsAction.php b/module/Rest/src/Action/Visit/GetVisitsAction.php index 68d50533..ca8c2838 100644 --- a/module/Rest/src/Action/Visit/GetVisitsAction.php +++ b/module/Rest/src/Action/Visit/GetVisitsAction.php @@ -8,15 +8,11 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; -use function sprintf; - class GetVisitsAction extends AbstractRestAction { use PaginatorUtilsTrait; @@ -36,19 +32,10 @@ class GetVisitsAction extends AbstractRestAction public function handle(Request $request): Response { $shortCode = $request->getAttribute('shortCode'); + $visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams())); - try { - $visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams())); - - return new JsonResponse([ - 'visits' => $this->serializePaginator($visits), - ]); - } catch (ShortUrlNotFoundException $e) { - $this->logger->warning('Provided nonexistent short code {e}', ['e' => $e]); - return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, // FIXME Wrong code. Use correct one in "type" - 'message' => sprintf('Provided short code %s does not exist', $shortCode), - ], self::STATUS_NOT_FOUND); - } + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); } } diff --git a/module/Rest/src/Exception/AuthenticationException.php b/module/Rest/src/Exception/AuthenticationException.php index 1613bac5..3e60edb6 100644 --- a/module/Rest/src/Exception/AuthenticationException.php +++ b/module/Rest/src/Exception/AuthenticationException.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest\Exception; use Throwable; +/** @deprecated */ class AuthenticationException extends RuntimeException { public static function expiredJWT(?Throwable $prev = null): self diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 97ca0d0b..3f4f1161 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -21,11 +21,14 @@ class RestUtils public const INVALID_ARGUMENT_ERROR = Core\ValidationException::TYPE; /** @deprecated */ public const INVALID_SLUG_ERROR = Core\NonUniqueSlugException::TYPE; + /** @deprecated */ public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; public const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; public const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION'; public const INVALID_API_KEY_ERROR = 'INVALID_API_KEY'; - public const NOT_FOUND_ERROR = 'NOT_FOUND'; + /** @deprecated */ + public const NOT_FOUND_ERROR = Core\TagNotFoundException::TYPE; + /** @deprecated */ public const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; /** @deprecated */ diff --git a/module/Rest/test-api/Action/GetVisitsActionTest.php b/module/Rest/test-api/Action/GetVisitsActionTest.php index 150c3066..d2c5f6eb 100644 --- a/module/Rest/test-api/Action/GetVisitsActionTest.php +++ b/module/Rest/test-api/Action/GetVisitsActionTest.php @@ -16,6 +16,6 @@ class GetVisitsActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 63aa1ffb..ff86117e 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -11,8 +11,6 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; -use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\ServerRequest; class EditShortUrlActionTest extends TestCase diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 9ded1716..3b068add 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; use Zend\Diactoros\ServerRequest; @@ -32,9 +32,10 @@ class UpdateTagActionTest extends TestCase public function whenInvalidParamsAreProvidedAnErrorIsReturned(array $bodyParams): void { $request = (new ServerRequest())->withParsedBody($bodyParams); - $resp = $this->action->handle($request); - $this->assertEquals(400, $resp->getStatusCode()); + $this->expectException(ValidationException::class); + + $this->action->handle($request); } public function provideParams(): iterable @@ -44,21 +45,6 @@ class UpdateTagActionTest extends TestCase yield 'no params' => [[]]; } - /** @test */ - public function requestingInvalidTagReturnsError(): void - { - $request = (new ServerRequest())->withParsedBody([ - 'oldName' => 'foo', - 'newName' => 'bar', - ]); - $rename = $this->tagService->renameTag('foo', 'bar')->willThrow(EntityDoesNotExistException::class); - - $resp = $this->action->handle($request); - - $this->assertEquals(404, $resp->getStatusCode()); - $rename->shouldHaveBeenCalled(); - } - /** @test */ public function correctInvocationRenamesTag(): void { diff --git a/module/Rest/test/Action/Visit/GetVisitsActionTest.php b/module/Rest/test/Action/Visit/GetVisitsActionTest.php index cb38dd85..80e06085 100644 --- a/module/Rest/test/Action/Visit/GetVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GetVisitsActionTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction; @@ -42,18 +41,6 @@ class GetVisitsActionTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); } - /** @test */ - public function providingInvalidShortCodeReturnsError(): void - { - $shortCode = 'abc123'; - $this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willThrow( - ShortUrlNotFoundException::class - )->shouldBeCalledOnce(); - - $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); - $this->assertEquals(404, $response->getStatusCode()); - } - /** @test */ public function paramsAreReadFromQuery(): void { diff --git a/module/Rest/test/Util/RestUtilsTest.php b/module/Rest/test/Util/RestUtilsTest.php index d8a45181..6097cdc6 100644 --- a/module/Rest/test/Util/RestUtilsTest.php +++ b/module/Rest/test/Util/RestUtilsTest.php @@ -6,8 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Util; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; -use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\Rest\Exception\AuthenticationException; use Shlinkio\Shlink\Rest\Util\RestUtils; From 13e795d25d33ff9b15220b70e800e6016ddbc9c4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 20:58:38 +0100 Subject: [PATCH 028/100] Updated ValidationException's base exception --- module/Core/src/Exception/ValidationException.php | 2 +- module/Core/src/Service/UrlShortener.php | 1 + .../Authentication/Plugin/AuthorizationHeaderPlugin.php | 1 + module/Rest/src/Util/RestUtils.php | 8 ++++---- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 510a31b0..eea23a5a 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -17,7 +17,7 @@ use function sprintf; use const PHP_EOL; -class ValidationException extends RuntimeException implements ProblemDetailsExceptionInterface +class ValidationException extends InvalidArgumentException implements ProblemDetailsExceptionInterface { use CommonProblemDetailsExceptionTrait; diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 0e4062ec..70112bd7 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -130,6 +130,7 @@ class UrlShortener implements UrlShortenerInterface /** * @throws ShortUrlNotFoundException + * @fixme Move this method to a different service */ public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl { diff --git a/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php b/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php index e5280ac6..d479d9d3 100644 --- a/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php +++ b/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php @@ -16,6 +16,7 @@ use function explode; use function sprintf; use function strtolower; +/** @deprecated */ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface { public const HEADER_NAME = 'Authorization'; diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 3f4f1161..6e2b77f0 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -22,14 +22,14 @@ class RestUtils /** @deprecated */ public const INVALID_SLUG_ERROR = Core\NonUniqueSlugException::TYPE; /** @deprecated */ + public const NOT_FOUND_ERROR = Core\TagNotFoundException::TYPE; + /** @deprecated */ + public const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; + public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; public const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; public const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION'; public const INVALID_API_KEY_ERROR = 'INVALID_API_KEY'; - /** @deprecated */ - public const NOT_FOUND_ERROR = Core\TagNotFoundException::TYPE; - /** @deprecated */ - public const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; /** @deprecated */ public static function getRestErrorCodeFromException(Throwable $e): string From 509c9fe2e8f782ebf7e1b728017c241d8e37313b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 21:29:25 +0100 Subject: [PATCH 029/100] Improved AuthenticationMiddleware API tests --- .../RequestToHttpAuthPlugin.php | 5 +- ...teShortUrlContentNegotiationMiddleware.php | 6 --- .../Action/CreateShortUrlActionTest.php | 3 +- .../Action/DeleteShortUrlActionTest.php | 5 +- .../Action/EditShortUrlActionTagsTest.php | 5 +- .../Action/EditShortUrlActionTest.php | 5 +- .../test-api/Action/GetVisitsActionTest.php | 3 +- .../Action/ResolveShortUrlActionTest.php | 3 +- .../test-api/Action/UpdateTagActionTest.php | 5 +- .../Middleware/AuthenticationTest.php | 50 +++++++++++++++++-- 10 files changed, 57 insertions(+), 33 deletions(-) diff --git a/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php b/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php index f0cc0fe4..9e3e28e0 100644 --- a/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php +++ b/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php @@ -36,10 +36,7 @@ class RequestToHttpAuthPlugin implements RequestToHttpAuthPluginInterface public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface { if (! $this->hasAnySupportedHeader($request)) { - throw NoAuthenticationException::fromExpectedTypes([ - Plugin\ApiKeyHeaderPlugin::HEADER_NAME, - Plugin\AuthorizationHeaderPlugin::HEADER_NAME, - ]); + throw NoAuthenticationException::fromExpectedTypes(self::SUPPORTED_AUTH_HEADERS); } return $this->authPluginManager->get($this->getFirstAvailableHeader($request)); diff --git a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php index 64da53d5..98b64401 100644 --- a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php @@ -21,12 +21,6 @@ class CreateShortUrlContentNegotiationMiddleware implements MiddlewareInterface private const PLAIN_TEXT = 'text'; private const JSON = 'json'; - /** - * Process an incoming server request and return a response, optionally delegating - * response creation to a handler. - * @throws \RuntimeException - * @throws \InvalidArgumentException - */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index 61c5c7a0..758bb4ac 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -6,7 +6,6 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function Functional\map; @@ -44,7 +43,7 @@ class CreateShortUrlActionTest extends ApiTestCase [$statusCode, $payload] = $this->createShortUrl(['customSlug' => $slug, 'domain' => $domain]); $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - $this->assertEquals(RestUtils::INVALID_SLUG_ERROR, $payload['error']); + $this->assertEquals('INVALID_SLUG', $payload['error']); } /** @test */ diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index 042b8e90..c6cf443e 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class DeleteShortUrlActionTest extends ApiTestCase @@ -16,7 +15,7 @@ class DeleteShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE', $error); } /** @test */ @@ -31,6 +30,6 @@ class DeleteShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_DELETION_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE_DELETION', $error); } } diff --git a/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php b/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php index d033fcee..e6cae9b6 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class EditShortUrlActionTagsTest extends ApiTestCase @@ -17,7 +16,7 @@ class EditShortUrlActionTagsTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + $this->assertEquals('INVALID_ARGUMENT', $error); } /** @test */ @@ -29,6 +28,6 @@ class EditShortUrlActionTagsTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE', $error); } } diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index 65f2fd1d..739ec191 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class EditShortUrlActionTest extends ApiTestCase @@ -17,7 +16,7 @@ class EditShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE', $error); } /** @test */ @@ -29,6 +28,6 @@ class EditShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + $this->assertEquals('INVALID_ARGUMENT', $error); } } diff --git a/module/Rest/test-api/Action/GetVisitsActionTest.php b/module/Rest/test-api/Action/GetVisitsActionTest.php index d2c5f6eb..f9c6f404 100644 --- a/module/Rest/test-api/Action/GetVisitsActionTest.php +++ b/module/Rest/test-api/Action/GetVisitsActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class GetVisitsActionTest extends ApiTestCase @@ -16,6 +15,6 @@ class GetVisitsActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE', $error); } } diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index 4065517e..46f07d53 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class ResolveShortUrlActionTest extends ApiTestCase @@ -16,6 +15,6 @@ class ResolveShortUrlActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $error); + $this->assertEquals('INVALID_SHORTCODE', $error); } } diff --git a/module/Rest/test-api/Action/UpdateTagActionTest.php b/module/Rest/test-api/Action/UpdateTagActionTest.php index 0f7c0400..12f37296 100644 --- a/module/Rest/test-api/Action/UpdateTagActionTest.php +++ b/module/Rest/test-api/Action/UpdateTagActionTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class UpdateTagActionTest extends ApiTestCase @@ -20,7 +19,7 @@ class UpdateTagActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $error); + $this->assertEquals('INVALID_ARGUMENT', $error); } public function provideInvalidBody(): iterable @@ -40,6 +39,6 @@ class UpdateTagActionTest extends ApiTestCase ['error' => $error] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(RestUtils::NOT_FOUND_ERROR, $error); + $this->assertEquals('TAG_NOT_FOUND', $error); } } diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index 02836c85..b918a369 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Middleware; -use Shlinkio\Shlink\Rest\Authentication\Plugin\ApiKeyHeaderPlugin; +use Shlinkio\Shlink\Rest\Authentication\Plugin; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function implode; @@ -21,7 +20,7 @@ class AuthenticationTest extends ApiTestCase ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_AUTHORIZATION_ERROR, $error); + $this->assertEquals('INVALID_AUTHORIZATION', $error); $this->assertEquals( sprintf( 'Expected one of the following authentication headers, but none were provided, ["%s"]', @@ -39,13 +38,13 @@ class AuthenticationTest extends ApiTestCase { $resp = $this->callApi(self::METHOD_GET, '/short-codes', [ 'headers' => [ - ApiKeyHeaderPlugin::HEADER_NAME => $apiKey, + Plugin\ApiKeyHeaderPlugin::HEADER_NAME => $apiKey, ], ]); ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals(RestUtils::INVALID_API_KEY_ERROR, $error); + $this->assertEquals('INVALID_API_KEY', $error); $this->assertEquals('Provided API key does not exist or is invalid.', $message); } @@ -55,4 +54,45 @@ class AuthenticationTest extends ApiTestCase yield 'key which is expired' => ['expired_api_key']; yield 'key which is disabled' => ['disabled_api_key']; } + + /** + * @test + * @dataProvider provideInvalidAuthorizations + */ + public function authorizationErrorIsReturnedIfInvalidDataIsProvided( + string $authValue, + string $expectedMessage, + string $expectedError + ): void { + $resp = $this->callApi(self::METHOD_GET, '/short-codes', [ + 'headers' => [ + Plugin\AuthorizationHeaderPlugin::HEADER_NAME => $authValue, + ], + ]); + ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); + $this->assertEquals($expectedError, $error); + $this->assertEquals($expectedMessage, $message); + } + + public function provideInvalidAuthorizations(): iterable + { + yield 'no type' => [ + 'invalid', + 'You need to provide the Bearer type in the Authorization header.', + 'INVALID_AUTHORIZATION', + ]; + yield 'invalid type' => [ + 'Basic invalid', + 'Provided authorization type Basic is not supported. Use Bearer instead.', + 'INVALID_AUTHORIZATION', + ]; + yield 'invalid JWT' => [ + 'Bearer invalid', + 'Missing or invalid auth token provided. Perform a new authentication request and send provided ' + . 'token on every new request on the Authorization header', + 'INVALID_AUTH_TOKEN', + ]; + } } From f502eb0195b83a89c7c94f764d31aa5c5934ff38 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 21:33:22 +0100 Subject: [PATCH 030/100] Added new test for the case in which an invalid URL is provided --- module/Rest/test-api/Action/CreateShortUrlActionTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index 758bb4ac..2098ea0c 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -200,6 +200,15 @@ class CreateShortUrlActionTest extends ApiTestCase yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io } + /** @test */ + public function failsToCreateShortUrlWithInvalidOriginalUrl(): void + { + [$statusCode, $payload] = $this->createShortUrl(['longUrl' => 'https://this-has-to-be-invalid.com']); + + $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); + $this->assertEquals('INVALID_URL', $payload['error']); + } + /** * @return array { * @var int $statusCode From 6f4e5175da5f82158f47a8cf311fa2ae9453b761 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 21:43:29 +0100 Subject: [PATCH 031/100] Converted MissingAuthenticationException into a problem details exception --- .../RequestToHttpAuthPlugin.php | 8 ++--- .../RequestToHttpAuthPluginInterface.php | 6 ++-- .../MissingAuthenticationException.php | 36 +++++++++++++++++++ .../Exception/NoAuthenticationException.php | 19 ---------- .../Middleware/AuthenticationMiddleware.php | 25 +------------ module/Rest/src/Util/RestUtils.php | 3 +- .../RequestToAuthPluginTest.php | 4 +-- .../AuthenticationMiddlewareTest.php | 4 +-- 8 files changed, 48 insertions(+), 57 deletions(-) create mode 100644 module/Rest/src/Exception/MissingAuthenticationException.php delete mode 100644 module/Rest/src/Exception/NoAuthenticationException.php diff --git a/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php b/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php index 9e3e28e0..c10c0321 100644 --- a/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php +++ b/module/Rest/src/Authentication/RequestToHttpAuthPlugin.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Authentication; -use Psr\Container; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException; +use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use function array_filter; use function array_reduce; @@ -30,13 +29,12 @@ class RequestToHttpAuthPlugin implements RequestToHttpAuthPluginInterface } /** - * @throws Container\ContainerExceptionInterface - * @throws NoAuthenticationException + * @throws MissingAuthenticationException */ public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface { if (! $this->hasAnySupportedHeader($request)) { - throw NoAuthenticationException::fromExpectedTypes(self::SUPPORTED_AUTH_HEADERS); + throw MissingAuthenticationException::fromExpectedTypes(self::SUPPORTED_AUTH_HEADERS); } return $this->authPluginManager->get($this->getFirstAvailableHeader($request)); diff --git a/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php b/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php index 18e68ee2..b8002431 100644 --- a/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php +++ b/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php @@ -4,15 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Authentication; -use Psr\Container; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException; +use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; interface RequestToHttpAuthPluginInterface { /** - * @throws Container\ContainerExceptionInterface - * @throws NoAuthenticationException + * @throws MissingAuthenticationException */ public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface; } diff --git a/module/Rest/src/Exception/MissingAuthenticationException.php b/module/Rest/src/Exception/MissingAuthenticationException.php new file mode 100644 index 00000000..6ab55458 --- /dev/null +++ b/module/Rest/src/Exception/MissingAuthenticationException.php @@ -0,0 +1,36 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + $e->additional = ['expectedTypes' => $expectedTypes]; + + return $e; + } +} diff --git a/module/Rest/src/Exception/NoAuthenticationException.php b/module/Rest/src/Exception/NoAuthenticationException.php deleted file mode 100644 index b5e8bfc8..00000000 --- a/module/Rest/src/Exception/NoAuthenticationException.php +++ /dev/null @@ -1,19 +0,0 @@ -logger = $logger ?: new NullLogger(); } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * @param Request $request - * @param RequestHandlerInterface $handler - * - * @return Response - * @throws \InvalidArgumentException - */ public function process(Request $request, RequestHandlerInterface $handler): Response { /** @var RouteResult|null $routeResult */ @@ -67,15 +52,7 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa return $handler->handle($request); } - try { - $plugin = $this->requestToAuthPlugin->fromRequest($request); - } catch (ContainerExceptionInterface | NoAuthenticationException $e) { - $this->logger->warning('Invalid or no authentication provided. {e}', ['e' => $e]); - return $this->createErrorResponse(sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', - implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) - )); - } + $plugin = $this->requestToAuthPlugin->fromRequest($request); try { $plugin->verify($request); diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 6e2b77f0..1dad3908 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -28,7 +28,8 @@ class RestUtils public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; public const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; - public const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION'; + /** @deprecated */ + public const INVALID_AUTHORIZATION_ERROR = Rest\MissingAuthenticationException::TYPE; public const INVALID_API_KEY_ERROR = 'INVALID_API_KEY'; /** @deprecated */ diff --git a/module/Rest/test/Authentication/RequestToAuthPluginTest.php b/module/Rest/test/Authentication/RequestToAuthPluginTest.php index e9d68489..d9005261 100644 --- a/module/Rest/test/Authentication/RequestToAuthPluginTest.php +++ b/module/Rest/test/Authentication/RequestToAuthPluginTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Rest\Authentication\Plugin\ApiKeyHeaderPlugin; use Shlinkio\Shlink\Rest\Authentication\Plugin\AuthenticationPluginInterface; use Shlinkio\Shlink\Rest\Authentication\Plugin\AuthorizationHeaderPlugin; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin; -use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException; +use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Zend\Diactoros\ServerRequest; use function implode; @@ -35,7 +35,7 @@ class RequestToAuthPluginTest extends TestCase { $request = new ServerRequest(); - $this->expectException(NoAuthenticationException::class); + $this->expectException(MissingAuthenticationException::class); $this->expectExceptionMessage(sprintf( 'None of the valid authentication mechanisms where provided. Expected one of ["%s"]', implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index e373fbea..054db85e 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -19,7 +19,7 @@ use Shlinkio\Shlink\Rest\Action\AuthenticateAction; use Shlinkio\Shlink\Rest\Authentication\Plugin\AuthenticationPluginInterface; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPluginInterface; -use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException; +use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use Shlinkio\Shlink\Rest\Util\RestUtils; @@ -128,7 +128,7 @@ class AuthenticationMiddlewareTest extends TestCase }; yield 'container exception' => [$containerException]; - yield 'authentication exception' => [NoAuthenticationException::fromExpectedTypes([])]; + yield 'authentication exception' => [MissingAuthenticationException::fromExpectedTypes([])]; } /** @test */ From 5213faa0a17ce1173ff45d9a2c387fab73f67021 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 22:03:40 +0100 Subject: [PATCH 032/100] Converted VerifyAuthenticationException into a problem details exception --- module/Rest/config/auth.config.php | 1 - module/Rest/src/Action/AuthenticateAction.php | 4 +- .../ShortUrl/EditShortUrlTagsAction.php | 2 +- .../Plugin/ApiKeyHeaderPlugin.php | 10 +-- .../Plugin/AuthorizationHeaderPlugin.php | 25 ++---- .../VerifyAuthenticationException.php | 81 ++++++++++++++----- .../Middleware/AuthenticationMiddleware.php | 36 ++------- module/Rest/src/Util/RestUtils.php | 46 +---------- .../VerifyAuthenticationExceptionTest.php | 30 ------- .../AuthenticationMiddlewareTest.php | 2 +- module/Rest/test/Util/RestUtilsTest.php | 41 ---------- 11 files changed, 79 insertions(+), 199 deletions(-) delete mode 100644 module/Rest/test/Util/RestUtilsTest.php diff --git a/module/Rest/config/auth.config.php b/module/Rest/config/auth.config.php index 7acb133e..9d0987e0 100644 --- a/module/Rest/config/auth.config.php +++ b/module/Rest/config/auth.config.php @@ -48,7 +48,6 @@ return [ Middleware\AuthenticationMiddleware::class => [ Authentication\RequestToHttpAuthPlugin::class, 'config.auth.routes_whitelist', - 'Logger_Shlink', ], ], diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 1476f6de..bfaa745a 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -44,7 +44,7 @@ class AuthenticateAction extends AbstractRestAction $authData = $request->getParsedBody(); if (! isset($authData['apiKey'])) { return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'error' => 'INVALID_ARGUMENT', 'message' => 'You have to provide a valid API key under the "apiKey" param name.', ], self::STATUS_BAD_REQUEST); } @@ -53,7 +53,7 @@ class AuthenticateAction extends AbstractRestAction $apiKey = $this->apiKeyService->getByKey($authData['apiKey']); if ($apiKey === null || ! $apiKey->isValid()) { return new JsonResponse([ - 'error' => RestUtils::INVALID_API_KEY_ERROR, + 'error' => 'INVALID_API_KEY', 'message' => 'Provided API key does not exist or is invalid.', ], self::STATUS_UNAUTHORIZED); } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 9d82c608..1ab26cbb 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -38,7 +38,7 @@ class EditShortUrlTagsAction extends AbstractRestAction if (! isset($bodyParams['tags'])) { return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'error' => 'INVALID_ARGUMENT', 'message' => 'A list of tags was not provided', ], self::STATUS_BAD_REQUEST); } diff --git a/module/Rest/src/Authentication/Plugin/ApiKeyHeaderPlugin.php b/module/Rest/src/Authentication/Plugin/ApiKeyHeaderPlugin.php index d5cd38f9..14735f9a 100644 --- a/module/Rest/src/Authentication/Plugin/ApiKeyHeaderPlugin.php +++ b/module/Rest/src/Authentication/Plugin/ApiKeyHeaderPlugin.php @@ -8,7 +8,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Shlinkio\Shlink\Rest\Util\RestUtils; class ApiKeyHeaderPlugin implements AuthenticationPluginInterface { @@ -28,14 +27,9 @@ class ApiKeyHeaderPlugin implements AuthenticationPluginInterface public function verify(ServerRequestInterface $request): void { $apiKey = $request->getHeaderLine(self::HEADER_NAME); - if ($this->apiKeyService->check($apiKey)) { - return; + if (! $this->apiKeyService->check($apiKey)) { + throw VerifyAuthenticationException::forInvalidApiKey(); } - - throw VerifyAuthenticationException::withError( - RestUtils::INVALID_API_KEY_ERROR, - 'Provided API key does not exist or is invalid.' - ); } public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface diff --git a/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php b/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php index d479d9d3..bf75d09b 100644 --- a/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php +++ b/module/Rest/src/Authentication/Plugin/AuthorizationHeaderPlugin.php @@ -8,7 +8,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Throwable; use function count; @@ -38,19 +37,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface $authToken = $request->getHeaderLine(self::HEADER_NAME); $authTokenParts = explode(' ', $authToken); if (count($authTokenParts) === 1) { - throw VerifyAuthenticationException::withError( - RestUtils::INVALID_AUTHORIZATION_ERROR, - sprintf('You need to provide the Bearer type in the %s header.', self::HEADER_NAME) - ); + throw VerifyAuthenticationException::forMissingAuthType(); } // Make sure the authorization type is Bearer [$authType, $jwt] = $authTokenParts; if (strtolower($authType) !== 'bearer') { - throw VerifyAuthenticationException::withError( - RestUtils::INVALID_AUTHORIZATION_ERROR, - sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $authType) - ); + throw VerifyAuthenticationException::forInvalidAuthType($authType); } try { @@ -58,21 +51,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface throw $this->createInvalidTokenError(); } } catch (Throwable $e) { - throw $this->createInvalidTokenError($e); + throw $this->createInvalidTokenError(); } } - private function createInvalidTokenError(?Throwable $prev = null): VerifyAuthenticationException + private function createInvalidTokenError(): VerifyAuthenticationException { - return VerifyAuthenticationException::withError( - RestUtils::INVALID_AUTH_TOKEN_ERROR, - sprintf( - 'Missing or invalid auth token provided. Perform a new authentication request and send provided ' - . 'token on every new request on the %s header', - self::HEADER_NAME - ), - $prev - ); + return VerifyAuthenticationException::forInvalidAuthToken(); } public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface diff --git a/module/Rest/src/Exception/VerifyAuthenticationException.php b/module/Rest/src/Exception/VerifyAuthenticationException.php index 4121685e..8720f421 100644 --- a/module/Rest/src/Exception/VerifyAuthenticationException.php +++ b/module/Rest/src/Exception/VerifyAuthenticationException.php @@ -4,38 +4,81 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Exception; -use Throwable; +use Fig\Http\Message\StatusCodeInterface; +use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; +use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use function sprintf; -class VerifyAuthenticationException extends RuntimeException +class VerifyAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface { + use CommonProblemDetailsExceptionTrait; + /** @var string */ private $errorCode; /** @var string */ private $publicMessage; - public function __construct( - string $errorCode, - string $publicMessage, - string $message = '', - int $code = 0, - ?Throwable $previous = null - ) { - parent::__construct($message, $code, $previous); - $this->errorCode = $errorCode; - $this->publicMessage = $publicMessage; + public static function forInvalidApiKey(): self + { + $e = new self('Provided API key does not exist or is invalid.'); + + $e->publicMessage = $e->getMessage(); + $e->errorCode = 'INVALID_API_KEY'; + $e->detail = $e->getMessage(); + $e->title = 'Invalid API key'; + $e->type = 'INVALID_API_KEY'; + $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + + return $e; } - public static function withError(string $errorCode, string $publicMessage, ?Throwable $prev = null): self + /** @deprecated */ + public static function forInvalidAuthToken(): self { - return new self( - $errorCode, - $publicMessage, - sprintf('Authentication verification failed with the public message "%s"', $publicMessage), - 0, - $prev + $e = new self( + 'Missing or invalid auth token provided. Perform a new authentication request and send provided ' + . 'token on every new request on the Authorization header' ); + + $e->publicMessage = $e->getMessage(); + $e->errorCode = 'INVALID_AUTH_TOKEN'; + $e->detail = $e->getMessage(); + $e->title = 'Invalid auth token'; + $e->type = 'INVALID_AUTH_TOKEN'; + $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + + return $e; + } + + /** @deprecated */ + public static function forMissingAuthType(): self + { + $e = new self('You need to provide the Bearer type in the Authorization header.'); + + $e->publicMessage = $e->getMessage(); + $e->errorCode = 'INVALID_AUTHORIZATION'; + $e->detail = $e->getMessage(); + $e->title = 'Invalid authorization'; + $e->type = 'INVALID_AUTHORIZATION'; + $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + + return $e; + } + + /** @deprecated */ + public static function forInvalidAuthType(string $providedType): self + { + $e = new self(sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $providedType)); + + $e->publicMessage = $e->getMessage(); + $e->errorCode = 'INVALID_AUTHORIZATION'; + $e->detail = $e->getMessage(); + $e->title = 'Invalid authorization'; + $e->type = 'INVALID_AUTHORIZATION'; + $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; + + return $e; } public function getErrorCode(): string diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index 41dec7dc..4fd44bcf 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -10,33 +10,22 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPluginInterface; -use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; -use Shlinkio\Shlink\Rest\Util\RestUtils; -use Zend\Diactoros\Response\JsonResponse; use Zend\Expressive\Router\RouteResult; use function Functional\contains; class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface { - /** @var LoggerInterface */ - private $logger; /** @var array */ private $routesWhitelist; /** @var RequestToHttpAuthPluginInterface */ private $requestToAuthPlugin; - public function __construct( - RequestToHttpAuthPluginInterface $requestToAuthPlugin, - array $routesWhitelist, - ?LoggerInterface $logger = null - ) { + public function __construct(RequestToHttpAuthPluginInterface $requestToAuthPlugin, array $routesWhitelist) + { $this->routesWhitelist = $routesWhitelist; $this->requestToAuthPlugin = $requestToAuthPlugin; - $this->logger = $logger ?: new NullLogger(); } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -53,24 +42,9 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa } $plugin = $this->requestToAuthPlugin->fromRequest($request); + $plugin->verify($request); + $response = $handler->handle($request); - try { - $plugin->verify($request); - $response = $handler->handle($request); - return $plugin->update($request, $response); - } catch (VerifyAuthenticationException $e) { - $this->logger->warning('Authentication verification failed. {e}', ['e' => $e]); - return $this->createErrorResponse($e->getPublicMessage(), $e->getErrorCode()); - } - } - - private function createErrorResponse( - string $message, - string $errorCode = RestUtils::INVALID_AUTHORIZATION_ERROR - ): JsonResponse { - return new JsonResponse([ - 'error' => $errorCode, - 'message' => $message, - ], self::STATUS_UNAUTHORIZED); + return $plugin->update($request, $response); } } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index 1dad3908..a6c349bf 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -4,54 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Util; -use Shlinkio\Shlink\Common\Exception as Common; -use Shlinkio\Shlink\Core\Exception as Core; use Shlinkio\Shlink\Rest\Exception as Rest; -use Throwable; +/** @deprecated */ class RestUtils { - /** @deprecated */ - public const INVALID_SHORTCODE_ERROR = Core\ShortUrlNotFoundException::TYPE; - /** @deprecated */ - public const INVALID_SHORTCODE_DELETION_ERROR = Core\DeleteShortUrlException::TYPE; - /** @deprecated */ - public const INVALID_URL_ERROR = Core\InvalidUrlException::TYPE; - /** @deprecated */ - public const INVALID_ARGUMENT_ERROR = Core\ValidationException::TYPE; - /** @deprecated */ - public const INVALID_SLUG_ERROR = Core\NonUniqueSlugException::TYPE; - /** @deprecated */ - public const NOT_FOUND_ERROR = Core\TagNotFoundException::TYPE; - /** @deprecated */ - public const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; - - public const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS'; - public const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN'; - /** @deprecated */ public const INVALID_AUTHORIZATION_ERROR = Rest\MissingAuthenticationException::TYPE; - public const INVALID_API_KEY_ERROR = 'INVALID_API_KEY'; - - /** @deprecated */ - public static function getRestErrorCodeFromException(Throwable $e): string - { - switch (true) { - case $e instanceof Core\ShortUrlNotFoundException: - return self::INVALID_SHORTCODE_ERROR; - case $e instanceof Core\InvalidUrlException: - return self::INVALID_URL_ERROR; - case $e instanceof Core\NonUniqueSlugException: - return self::INVALID_SLUG_ERROR; - case $e instanceof Common\InvalidArgumentException: - case $e instanceof Core\InvalidArgumentException: - case $e instanceof Core\ValidationException: - return self::INVALID_ARGUMENT_ERROR; - case $e instanceof Rest\AuthenticationException: - return self::INVALID_CREDENTIALS_ERROR; - case $e instanceof Core\DeleteShortUrlException: - return self::INVALID_SHORTCODE_DELETION_ERROR; - default: - return self::UNKNOWN_ERROR; - } - } } diff --git a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php index d19fe0f6..9554c62d 100644 --- a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php +++ b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php @@ -13,41 +13,11 @@ use Throwable; use function array_map; use function random_int; use function range; -use function sprintf; class VerifyAuthenticationExceptionTest extends TestCase { use StringUtilsTrait; - /** - * @test - * @dataProvider provideExceptionData - */ - public function withErrorCreatesExpectedException(string $code, string $message, ?Throwable $prev): void - { - $e = VerifyAuthenticationException::withError($code, $message, $prev); - - $this->assertEquals(0, $e->getCode()); - $this->assertEquals( - sprintf('Authentication verification failed with the public message "%s"', $message), - $e->getMessage() - ); - $this->assertEquals($code, $e->getErrorCode()); - $this->assertEquals($message, $e->getPublicMessage()); - $this->assertEquals($prev, $e->getPrevious()); - } - - public function provideExceptionData(): iterable - { - return array_map(function () { - return [ - $this->generateRandomString(), - $this->generateRandomString(50), - random_int(0, 1) === 1 ? new Exception('Prev') : null, - ]; - }, range(1, 10)); - } - /** * @test * @dataProvider provideConstructorData diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 054db85e..eb150f57 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -138,7 +138,7 @@ class AuthenticationMiddlewareTest extends TestCase RouteResult::class, RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []) ); - $e = VerifyAuthenticationException::withError('the_error', 'the_message'); + $e = VerifyAuthenticationException::forInvalidApiKey(); $plugin = $this->prophesize(AuthenticationPluginInterface::class); $verify = $plugin->verify($request)->willThrow($e); diff --git a/module/Rest/test/Util/RestUtilsTest.php b/module/Rest/test/Util/RestUtilsTest.php deleted file mode 100644 index 6097cdc6..00000000 --- a/module/Rest/test/Util/RestUtilsTest.php +++ /dev/null @@ -1,41 +0,0 @@ -assertEquals( - RestUtils::INVALID_SHORTCODE_ERROR, - RestUtils::getRestErrorCodeFromException(new ShortUrlNotFoundException()) - ); - $this->assertEquals( - RestUtils::INVALID_URL_ERROR, - RestUtils::getRestErrorCodeFromException(new InvalidUrlException()) - ); - $this->assertEquals( - RestUtils::INVALID_ARGUMENT_ERROR, - RestUtils::getRestErrorCodeFromException(new InvalidArgumentException()) - ); - $this->assertEquals( - RestUtils::INVALID_CREDENTIALS_ERROR, - RestUtils::getRestErrorCodeFromException(new AuthenticationException()) - ); - $this->assertEquals( - RestUtils::UNKNOWN_ERROR, - RestUtils::getRestErrorCodeFromException(new WrongIpException()) - ); - } -} From 3b56fc37608eeec4ea6c2237f804df46b900d060 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 22:12:52 +0100 Subject: [PATCH 033/100] Refactored and fixed unit tests --- .../src/Exception/DeleteShortUrlException.php | 2 +- .../src/Exception/InvalidUrlException.php | 2 +- .../src/Exception/NonUniqueSlugException.php | 2 +- .../Exception/ShortUrlNotFoundException.php | 2 +- .../src/Exception/TagNotFoundException.php | 2 +- .../src/Exception/ValidationException.php | 2 +- module/Rest/src/Action/AuthenticateAction.php | 1 - .../ShortUrl/EditShortUrlTagsAction.php | 1 - .../MissingAuthenticationException.php | 2 +- .../VerifyAuthenticationException.php | 23 ------ module/Rest/src/Util/RestUtils.php | 13 ---- .../RequestToAuthPluginTest.php | 2 +- .../VerifyAuthenticationExceptionTest.php | 52 +------------ .../AuthenticationMiddlewareTest.php | 75 ------------------- phpstan.neon | 1 - 15 files changed, 11 insertions(+), 171 deletions(-) delete mode 100644 module/Rest/src/Util/RestUtils.php diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index 037fe942..984c2cbc 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -16,7 +16,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE use CommonProblemDetailsExceptionTrait; private const TITLE = 'Cannot delete short URL'; - public const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION + private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION /** @var int */ private $visitsThreshold; diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php index 32758b4e..0b741910 100644 --- a/module/Core/src/Exception/InvalidUrlException.php +++ b/module/Core/src/Exception/InvalidUrlException.php @@ -16,7 +16,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid URL'; - public const TYPE = 'INVALID_URL'; + private const TYPE = 'INVALID_URL'; public static function fromUrl(string $url, ?Throwable $previous = null): self { diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 74e03f06..9ef7365d 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -15,7 +15,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid custom slug'; - public const TYPE = 'INVALID_SLUG'; + private const TYPE = 'INVALID_SLUG'; public static function fromSlug(string $slug, ?string $domain): self { diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index e07624c7..9617a486 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -15,7 +15,7 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail use CommonProblemDetailsExceptionTrait; private const TITLE = 'Short URL not found'; - public const TYPE = 'INVALID_SHORTCODE'; + private const TYPE = 'INVALID_SHORTCODE'; public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self { diff --git a/module/Core/src/Exception/TagNotFoundException.php b/module/Core/src/Exception/TagNotFoundException.php index 6ff36bb2..33cf1345 100644 --- a/module/Core/src/Exception/TagNotFoundException.php +++ b/module/Core/src/Exception/TagNotFoundException.php @@ -15,7 +15,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce use CommonProblemDetailsExceptionTrait; private const TITLE = 'Tag not found'; - public const TYPE = 'TAG_NOT_FOUND'; + private const TYPE = 'TAG_NOT_FOUND'; public static function fromTag(string $tag): self { diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index eea23a5a..c4ff2354 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -22,7 +22,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid data'; - public const TYPE = 'INVALID_ARGUMENT'; + private const TYPE = 'INVALID_ARGUMENT'; /** @var array */ private $invalidElements; diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index bfaa745a..b0919aae 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -10,7 +10,6 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; /** @deprecated */ diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 1ab26cbb..3f057e75 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -9,7 +9,6 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; class EditShortUrlTagsAction extends AbstractRestAction diff --git a/module/Rest/src/Exception/MissingAuthenticationException.php b/module/Rest/src/Exception/MissingAuthenticationException.php index 6ab55458..6ed76e2a 100644 --- a/module/Rest/src/Exception/MissingAuthenticationException.php +++ b/module/Rest/src/Exception/MissingAuthenticationException.php @@ -16,7 +16,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem use CommonProblemDetailsExceptionTrait; private const TITLE = 'Invalid authorization'; - public const TYPE = 'INVALID_AUTHORIZATION'; + private const TYPE = 'INVALID_AUTHORIZATION'; public static function fromExpectedTypes(array $expectedTypes): self { diff --git a/module/Rest/src/Exception/VerifyAuthenticationException.php b/module/Rest/src/Exception/VerifyAuthenticationException.php index 8720f421..3b2a4115 100644 --- a/module/Rest/src/Exception/VerifyAuthenticationException.php +++ b/module/Rest/src/Exception/VerifyAuthenticationException.php @@ -14,17 +14,10 @@ class VerifyAuthenticationException extends RuntimeException implements ProblemD { use CommonProblemDetailsExceptionTrait; - /** @var string */ - private $errorCode; - /** @var string */ - private $publicMessage; - public static function forInvalidApiKey(): self { $e = new self('Provided API key does not exist or is invalid.'); - $e->publicMessage = $e->getMessage(); - $e->errorCode = 'INVALID_API_KEY'; $e->detail = $e->getMessage(); $e->title = 'Invalid API key'; $e->type = 'INVALID_API_KEY'; @@ -41,8 +34,6 @@ class VerifyAuthenticationException extends RuntimeException implements ProblemD . 'token on every new request on the Authorization header' ); - $e->publicMessage = $e->getMessage(); - $e->errorCode = 'INVALID_AUTH_TOKEN'; $e->detail = $e->getMessage(); $e->title = 'Invalid auth token'; $e->type = 'INVALID_AUTH_TOKEN'; @@ -56,8 +47,6 @@ class VerifyAuthenticationException extends RuntimeException implements ProblemD { $e = new self('You need to provide the Bearer type in the Authorization header.'); - $e->publicMessage = $e->getMessage(); - $e->errorCode = 'INVALID_AUTHORIZATION'; $e->detail = $e->getMessage(); $e->title = 'Invalid authorization'; $e->type = 'INVALID_AUTHORIZATION'; @@ -71,8 +60,6 @@ class VerifyAuthenticationException extends RuntimeException implements ProblemD { $e = new self(sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $providedType)); - $e->publicMessage = $e->getMessage(); - $e->errorCode = 'INVALID_AUTHORIZATION'; $e->detail = $e->getMessage(); $e->title = 'Invalid authorization'; $e->type = 'INVALID_AUTHORIZATION'; @@ -80,14 +67,4 @@ class VerifyAuthenticationException extends RuntimeException implements ProblemD return $e; } - - public function getErrorCode(): string - { - return $this->errorCode; - } - - public function getPublicMessage(): string - { - return $this->publicMessage; - } } diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php deleted file mode 100644 index a6c349bf..00000000 --- a/module/Rest/src/Util/RestUtils.php +++ /dev/null @@ -1,13 +0,0 @@ -expectException(MissingAuthenticationException::class); $this->expectExceptionMessage(sprintf( - 'None of the valid authentication mechanisms where provided. Expected one of ["%s"]', + 'Expected one of the following authentication headers, but none were provided, ["%s"]', implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) )); diff --git a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php index 9554c62d..28563c5f 100644 --- a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php +++ b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php @@ -4,62 +4,16 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Exception; -use Exception; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Common\Util\StringUtilsTrait; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; -use Throwable; - -use function array_map; -use function random_int; -use function range; class VerifyAuthenticationExceptionTest extends TestCase { - use StringUtilsTrait; - - /** - * @test - * @dataProvider provideConstructorData - */ - public function constructCreatesExpectedException( - string $errorCode, - string $publicMessage, - string $message, - int $code, - ?Throwable $prev - ): void { - $e = new VerifyAuthenticationException($errorCode, $publicMessage, $message, $code, $prev); - - $this->assertEquals($code, $e->getCode()); - $this->assertEquals($message, $e->getMessage()); - $this->assertEquals($errorCode, $e->getErrorCode()); - $this->assertEquals($publicMessage, $e->getPublicMessage()); - $this->assertEquals($prev, $e->getPrevious()); - } - - public function provideConstructorData(): iterable - { - return array_map(function (int $i) { - return [ - $this->generateRandomString(), - $this->generateRandomString(30), - $this->generateRandomString(50), - $i, - random_int(0, 1) === 1 ? new Exception('Prev') : null, - ]; - }, range(10, 20)); - } - /** @test */ - public function defaultConstructorValuesAreKept(): void + public function createsExpectedExceptionForInvalidApiKey(): void { - $e = new VerifyAuthenticationException('foo', 'bar'); + $e = VerifyAuthenticationException::forInvalidApiKey(); - $this->assertEquals(0, $e->getCode()); - $this->assertEquals('', $e->getMessage()); - $this->assertEquals('foo', $e->getErrorCode()); - $this->assertEquals('bar', $e->getPublicMessage()); - $this->assertNull($e->getPrevious()); + $this->assertEquals('Provided API key does not exist or is invalid.', $e->getMessage()); } } diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index eb150f57..36dfaeee 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -4,12 +4,10 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Middleware; -use Exception; use Fig\Http\Message\RequestMethodInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Psr\Container\ContainerExceptionInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -17,20 +15,13 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Rest\Action\AuthenticateAction; use Shlinkio\Shlink\Rest\Authentication\Plugin\AuthenticationPluginInterface; -use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin; use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPluginInterface; -use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; -use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; -use Shlinkio\Shlink\Rest\Util\RestUtils; -use Throwable; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequest; use Zend\Expressive\Router\Route; use Zend\Expressive\Router\RouteResult; -use function implode; -use function sprintf; use function Zend\Stratigility\middleware; class AuthenticationMiddlewareTest extends TestCase @@ -93,72 +84,6 @@ class AuthenticationMiddlewareTest extends TestCase )->withMethod(RequestMethodInterface::METHOD_OPTIONS)]; } - /** - * @test - * @dataProvider provideExceptions - */ - public function errorIsReturnedWhenNoValidAuthIsProvided(Throwable $e): void - { - $request = (new ServerRequest())->withAttribute( - RouteResult::class, - RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []) - ); - $fromRequest = $this->requestToPlugin->fromRequest(Argument::any())->willThrow($e); - $logWarning = $this->logger->warning('Invalid or no authentication provided. {e}', ['e' => $e])->will( - function () { - } - ); - - /** @var Response\JsonResponse $response */ - $response = $this->middleware->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - $payload = $response->getPayload(); - - $this->assertEquals(RestUtils::INVALID_AUTHORIZATION_ERROR, $payload['error']); - $this->assertEquals(sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', - implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) - ), $payload['message']); - $fromRequest->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - } - - public function provideExceptions(): iterable - { - $containerException = new class extends Exception implements ContainerExceptionInterface { - }; - - yield 'container exception' => [$containerException]; - yield 'authentication exception' => [MissingAuthenticationException::fromExpectedTypes([])]; - } - - /** @test */ - public function errorIsReturnedWhenVerificationFails(): void - { - $request = (new ServerRequest())->withAttribute( - RouteResult::class, - RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []) - ); - $e = VerifyAuthenticationException::forInvalidApiKey(); - $plugin = $this->prophesize(AuthenticationPluginInterface::class); - - $verify = $plugin->verify($request)->willThrow($e); - $fromRequest = $this->requestToPlugin->fromRequest(Argument::any())->willReturn($plugin->reveal()); - $logWarning = $this->logger->warning('Authentication verification failed. {e}', ['e' => $e])->will( - function () { - } - ); - - /** @var Response\JsonResponse $response */ - $response = $this->middleware->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - $payload = $response->getPayload(); - - $this->assertEquals('the_error', $payload['error']); - $this->assertEquals('the_message', $payload['message']); - $verify->shouldHaveBeenCalledOnce(); - $fromRequest->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - } - /** @test */ public function updatedResponseIsReturnedWhenVerificationPasses(): void { diff --git a/phpstan.neon b/phpstan.neon index 7df6d88f..2d9d960d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ parameters: ignoreErrors: - - '#is not subtype of Throwable#' - '#Undefined variable: \$metadata#' - '#AbstractQuery::setParameters()#' - '#mustRun()#' From fffb2872ef36cc34309615200fb421ef1443bc84 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 26 Nov 2019 22:18:55 +0100 Subject: [PATCH 034/100] Replaced hardcoded error response by the use of a problem details action --- .../src/Action/ShortUrl/EditShortUrlTagsAction.php | 13 ++++--------- ...nTagsTest.php => EditShortUrlTagsActionTest.php} | 2 +- .../Action/ShortUrl/EditShortUrlTagsActionTest.php | 5 +++-- 3 files changed, 8 insertions(+), 12 deletions(-) rename module/Rest/test-api/Action/{EditShortUrlActionTagsTest.php => EditShortUrlTagsActionTest.php} (95%) diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 3f057e75..208be169 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Zend\Diactoros\Response\JsonResponse; @@ -25,21 +26,15 @@ class EditShortUrlTagsAction extends AbstractRestAction $this->shortUrlService = $shortUrlService; } - /** - * @param Request $request - * @return Response - * @throws \InvalidArgumentException - */ public function handle(Request $request): Response { $shortCode = $request->getAttribute('shortCode'); $bodyParams = $request->getParsedBody(); if (! isset($bodyParams['tags'])) { - return new JsonResponse([ - 'error' => 'INVALID_ARGUMENT', - 'message' => 'A list of tags was not provided', - ], self::STATUS_BAD_REQUEST); + throw ValidationException::fromArray([ + 'tags' => 'List of tags has to be provided', + ]); } $tags = $bodyParams['tags']; diff --git a/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php similarity index 95% rename from module/Rest/test-api/Action/EditShortUrlActionTagsTest.php rename to module/Rest/test-api/Action/EditShortUrlTagsActionTest.php index e6cae9b6..5116d1e6 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTagsTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -class EditShortUrlActionTagsTest extends ApiTestCase +class EditShortUrlTagsActionTest extends ApiTestCase { /** @test */ public function notProvidingTagsReturnsBadRequest(): void diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index 5c0ec628..17293f05 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; use Zend\Diactoros\ServerRequest; @@ -27,8 +28,8 @@ class EditShortUrlTagsActionTest extends TestCase /** @test */ public function notProvidingTagsReturnsError(): void { - $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', 'abc123')); - $this->assertEquals(400, $response->getStatusCode()); + $this->expectException(ValidationException::class); + $this->action->handle((new ServerRequest())->withAttribute('shortCode', 'abc123')); } /** @test */ From 5266743a0c19842618f89c86f1b0546962c53eaa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Nov 2019 20:18:36 +0100 Subject: [PATCH 035/100] Added as much additional data as possible to exceptions --- .../ShortUrl/DeleteShortUrlCommandTest.php | 4 +-- .../src/Exception/DeleteShortUrlException.php | 19 ++++-------- .../src/Exception/InvalidUrlException.php | 1 + .../src/Exception/NonUniqueSlugException.php | 5 ++++ .../Exception/ShortUrlNotFoundException.php | 5 ++++ .../src/Exception/TagNotFoundException.php | 1 + .../src/Exception/ValidationException.php | 23 +++------------ .../Exception/DeleteShortUrlExceptionTest.php | 23 ++------------- .../Exception/ValidationExceptionTest.php | 29 ------------------- 9 files changed, 26 insertions(+), 84 deletions(-) diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 76414481..6a036c62 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -83,7 +83,7 @@ class DeleteShortUrlCommandTest extends TestCase $ignoreThreshold = array_pop($args); if (!$ignoreThreshold) { - throw new Exception\DeleteShortUrlException(10); + throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, ''); } } ); @@ -112,7 +112,7 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( - new Exception\DeleteShortUrlException(10) + Exception\DeleteShortUrlException::fromVisitsThreshold(10, '') ); $this->commandTester->setInputs(['no']); diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index 984c2cbc..ddd77418 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; -use Throwable; use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; @@ -18,18 +17,9 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE private const TITLE = 'Cannot delete short URL'; private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION - /** @var int */ - private $visitsThreshold; - - public function __construct(int $visitsThreshold, string $message = '', int $code = 0, ?Throwable $previous = null) - { - $this->visitsThreshold = $visitsThreshold; - parent::__construct($message, $code, $previous); - } - public static function fromVisitsThreshold(int $threshold, string $shortCode): self { - $e = new self($threshold, sprintf( + $e = new self(sprintf( 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', $shortCode, $threshold @@ -39,13 +29,16 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY; - $e->additional = ['threshold' => $threshold]; + $e->additional = [ + 'shortCode' => $shortCode, + 'threshold' => $threshold, + ]; return $e; } public function getVisitsThreshold(): int { - return $this->visitsThreshold; + return $this->additional['threshold']; } } diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php index 0b741910..ffec94df 100644 --- a/module/Core/src/Exception/InvalidUrlException.php +++ b/module/Core/src/Exception/InvalidUrlException.php @@ -27,6 +27,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep $e->title = self::TITLE; $e->type = self::TYPE; $e->status = $status; + $e->additional = ['url' => $url]; return $e; } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 9ef7365d..bcd40268 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -26,6 +26,11 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + $e->additional = ['customSlug' => $slug]; + + if ($domain !== null) { + $e->additional['domain'] = $domain; + } return $e; } diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index 9617a486..aca1bce9 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -26,6 +26,11 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + $e->additional = ['shortCode' => $shortCode]; + + if ($domain !== null) { + $e->additional['domain'] = $domain; + } return $e; } diff --git a/module/Core/src/Exception/TagNotFoundException.php b/module/Core/src/Exception/TagNotFoundException.php index 33cf1345..1924e89a 100644 --- a/module/Core/src/Exception/TagNotFoundException.php +++ b/module/Core/src/Exception/TagNotFoundException.php @@ -25,6 +25,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + $e->additional = ['tag' => $tag]; return $e; } diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index c4ff2354..f3a5c515 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -24,19 +24,6 @@ class ValidationException extends InvalidArgumentException implements ProblemDet private const TITLE = 'Invalid data'; private const TYPE = 'INVALID_ARGUMENT'; - /** @var array */ - private $invalidElements; - - public function __construct( - string $message = '', - array $invalidElements = [], - int $code = 0, - ?Throwable $previous = null - ) { - $this->invalidElements = $invalidElements; - parent::__construct($message, $code, $previous); - } - public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self { return static::fromArray($inputFilter->getMessages(), $prev); @@ -45,22 +32,20 @@ class ValidationException extends InvalidArgumentException implements ProblemDet public static function fromArray(array $invalidData, ?Throwable $prev = null): self { $status = StatusCodeInterface::STATUS_BAD_REQUEST; - $e = new self('Provided data is not valid', $invalidData, $status, $prev); + $e = new self('Provided data is not valid', $status, $prev); $e->detail = $e->getMessage(); $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; - $e->additional = [ - 'invalidElements' => $invalidData, - ]; + $e->additional = ['invalidElements' => $invalidData]; return $e; } public function getInvalidElements(): array { - return $this->invalidElements; + return $this->additional['invalidElements']; } public function __toString(): string @@ -80,7 +65,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet private function invalidElementsToString(): string { - return reduce_left($this->invalidElements, function ($messageSet, string $name, $_, string $acc) { + return reduce_left($this->getInvalidElements(), function ($messageSet, string $name, $_, string $acc) { return $acc . sprintf( "\n '%s' => %s", $name, diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index 4b132eea..0e2012a2 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -5,17 +5,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Common\Util\StringUtilsTrait; use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use function Functional\map; use function range; +use function Shlinkio\Shlink\Core\generateRandomShortCode; use function sprintf; class DeleteShortUrlExceptionTest extends TestCase { - use StringUtilsTrait; - /** * @test * @dataProvider provideThresholds @@ -29,29 +27,12 @@ class DeleteShortUrlExceptionTest extends TestCase $this->assertEquals($threshold, $e->getVisitsThreshold()); $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals(0, $e->getCode()); - $this->assertNull($e->getPrevious()); - } - - /** - * @test - * @dataProvider provideThresholds - */ - public function visitsThresholdIsProperlyReturned(int $threshold): void - { - $e = new DeleteShortUrlException($threshold); - - $this->assertEquals($threshold, $e->getVisitsThreshold()); - $this->assertEquals('', $e->getMessage()); - $this->assertEquals(0, $e->getCode()); - $this->assertNull($e->getPrevious()); } public function provideThresholds(): array { return map(range(5, 50, 5), function (int $number) { - $shortCode = $this->generateRandomString(6); - return [$number, $shortCode, sprintf( + return [$number, $shortCode = generateRandomShortCode(6), sprintf( 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', $shortCode, $number diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index eb47caed..069506b5 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -13,38 +13,9 @@ use Throwable; use Zend\InputFilter\InputFilterInterface; use function print_r; -use function random_int; class ValidationExceptionTest extends TestCase { - /** - * @test - * @dataProvider provideExceptionData - */ - public function createsExceptionWrappingExpectedData( - array $args, - string $expectedMessage, - array $expectedInvalidElements, - int $expectedCode, - ?Throwable $expectedPrev - ): void { - $e = new ValidationException(...$args); - - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedInvalidElements, $e->getInvalidElements()); - $this->assertEquals($expectedCode, $e->getCode()); - $this->assertEquals($expectedPrev, $e->getPrevious()); - } - - public function provideExceptionData(): iterable - { - yield 'empty args' => [[], '', [], 0, null]; - yield 'with message' => [['something'], 'something', [], 0, null]; - yield 'with elements' => [['something_else', [1, 2, 3]], 'something_else', [1, 2, 3], 0, null]; - yield 'with code' => [['foo', [], $foo = random_int(-100, 100)], 'foo', [], $foo, null]; - yield 'with prev' => [['bar', [], 8, $e = new RuntimeException()], 'bar', [], 8, $e]; - } - /** * @test * @dataProvider provideExceptions From d83d2f82bdf27b512e416bce05677073087af593 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 27 Nov 2019 20:48:35 +0100 Subject: [PATCH 036/100] Added more strict checks on API errors tests --- .../Action/CreateShortUrlActionTest.php | 31 +++++++++-- .../Action/DeleteShortUrlActionTest.php | 22 ++++++-- .../Action/EditShortUrlActionTest.php | 23 +++++++-- .../Action/EditShortUrlTagsActionTest.php | 23 +++++++-- .../test-api/Action/GetVisitsActionTest.php | 12 ++++- .../Action/ResolveShortUrlActionTest.php | 12 ++++- .../test-api/Action/UpdateTagActionTest.php | 22 ++++++-- .../Middleware/AuthenticationTest.php | 51 ++++++++++++------- 8 files changed, 156 insertions(+), 40 deletions(-) diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index 2098ea0c..37a2630c 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function Functional\map; use function range; +use function sprintf; class CreateShortUrlActionTest extends ApiTestCase { @@ -40,10 +41,25 @@ class CreateShortUrlActionTest extends ApiTestCase */ public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, ?string $domain): void { + $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); + $detail = sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix); + [$statusCode, $payload] = $this->createShortUrl(['customSlug' => $slug, 'domain' => $domain]); $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - $this->assertEquals('INVALID_SLUG', $payload['error']); + $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + $this->assertEquals($detail, $payload['detail']); + $this->assertEquals($detail, $payload['message']); // Deprecated + $this->assertEquals('INVALID_SLUG', $payload['type']); + $this->assertEquals('INVALID_SLUG', $payload['error']); // Deprecated + $this->assertEquals('Invalid custom slug', $payload['title']); + $this->assertEquals($slug, $payload['customSlug']); + + if ($domain !== null) { + $this->assertEquals($domain, $payload['domain']); + } else { + $this->assertArrayNotHasKey('domain', $payload); + } } /** @test */ @@ -203,10 +219,19 @@ class CreateShortUrlActionTest extends ApiTestCase /** @test */ public function failsToCreateShortUrlWithInvalidOriginalUrl(): void { - [$statusCode, $payload] = $this->createShortUrl(['longUrl' => 'https://this-has-to-be-invalid.com']); + $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]); $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - $this->assertEquals('INVALID_URL', $payload['error']); + $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + $this->assertEquals('INVALID_URL', $payload['type']); + $this->assertEquals('INVALID_URL', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid URL', $payload['title']); + $this->assertEquals($url, $payload['url']); } /** diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index c6cf443e..4680a6f6 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -11,11 +11,19 @@ class DeleteShortUrlActionTest extends ApiTestCase /** @test */ public function notFoundErrorIsReturnWhenDeletingInvalidUrl(): void { + $expectedDetail = 'No URL found with short code "invalid"'; + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/invalid'); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Short URL not found', $payload['title']); + $this->assertEquals('invalid', $payload['shortCode']); } /** @test */ @@ -25,11 +33,17 @@ class DeleteShortUrlActionTest extends ApiTestCase for ($i = 0; $i < 20; $i++) { $this->assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode()); } + $expectedDetail = 'Impossible to delete short URL with short code "abc123" since it has more than "15" visits.'; $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123'); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE_DELETION', $error); + $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE_DELETION', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE_DELETION', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Cannot delete short URL', $payload['title']); } } diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index 739ec191..bbcb043a 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -12,22 +12,37 @@ class EditShortUrlActionTest extends ApiTestCase /** @test */ public function tryingToEditInvalidUrlReturnsNotFoundError(): void { + $expectedDetail = 'No URL found with short code "invalid"'; + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => []]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Short URL not found', $payload['title']); + $this->assertEquals('invalid', $payload['shortCode']); } /** @test */ public function providingInvalidDataReturnsBadRequest(): void { + $expectedDetail = 'Provided data is not valid'; + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => [ 'maxVisits' => 'not_a_number', ]]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals('INVALID_ARGUMENT', $error); + $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + $this->assertEquals('INVALID_ARGUMENT', $payload['type']); + $this->assertEquals('INVALID_ARGUMENT', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid data', $payload['title']); } } diff --git a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php index 5116d1e6..e2922092 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php @@ -12,22 +12,37 @@ class EditShortUrlTagsActionTest extends ApiTestCase /** @test */ public function notProvidingTagsReturnsBadRequest(): void { + $expectedDetail = 'Provided data is not valid'; + $resp = $this->callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => []]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals('INVALID_ARGUMENT', $error); + $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + $this->assertEquals('INVALID_ARGUMENT', $payload['type']); + $this->assertEquals('INVALID_ARGUMENT', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid data', $payload['title']); } /** @test */ public function providingInvalidShortCodeReturnsBadRequest(): void { + $expectedDetail = 'No URL found with short code "invalid"'; + $resp = $this->callApiWithKey(self::METHOD_PUT, '/short-urls/invalid/tags', [RequestOptions::JSON => [ 'tags' => ['foo', 'bar'], ]]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Short URL not found', $payload['title']); + $this->assertEquals('invalid', $payload['shortCode']); } } diff --git a/module/Rest/test-api/Action/GetVisitsActionTest.php b/module/Rest/test-api/Action/GetVisitsActionTest.php index f9c6f404..0db06848 100644 --- a/module/Rest/test-api/Action/GetVisitsActionTest.php +++ b/module/Rest/test-api/Action/GetVisitsActionTest.php @@ -11,10 +11,18 @@ class GetVisitsActionTest extends ApiTestCase /** @test */ public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError(): void { + $expectedDetail = 'No URL found with short code "invalid"'; + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid/visits'); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Short URL not found', $payload['title']); + $this->assertEquals('invalid', $payload['shortCode']); } } diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index 46f07d53..e4d45f4a 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -11,10 +11,18 @@ class ResolveShortUrlActionTest extends ApiTestCase /** @test */ public function tryingToResolveInvalidUrlReturnsNotFoundError(): void { + $expectedDetail = 'No URL found with short code "invalid"'; + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid'); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('INVALID_SHORTCODE', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('INVALID_SHORTCODE', $payload['type']); + $this->assertEquals('INVALID_SHORTCODE', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Short URL not found', $payload['title']); + $this->assertEquals('invalid', $payload['shortCode']); } } diff --git a/module/Rest/test-api/Action/UpdateTagActionTest.php b/module/Rest/test-api/Action/UpdateTagActionTest.php index 12f37296..8c14774c 100644 --- a/module/Rest/test-api/Action/UpdateTagActionTest.php +++ b/module/Rest/test-api/Action/UpdateTagActionTest.php @@ -15,11 +15,18 @@ class UpdateTagActionTest extends ApiTestCase */ public function notProvidingTagsReturnsBadRequest(array $body): void { + $expectedDetail = 'Provided data is not valid'; + $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => $body]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals('INVALID_ARGUMENT', $error); + $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + $this->assertEquals('INVALID_ARGUMENT', $payload['type']); + $this->assertEquals('INVALID_ARGUMENT', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid data', $payload['title']); } public function provideInvalidBody(): iterable @@ -32,13 +39,20 @@ class UpdateTagActionTest extends ApiTestCase /** @test */ public function tryingToRenameInvalidTagReturnsNotFound(): void { + $expectedDetail = 'Tag with name "invalid_tag" could not be found'; + $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [ 'oldName' => 'invalid_tag', 'newName' => 'foo', ]]); - ['error' => $error] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals('TAG_NOT_FOUND', $error); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('TAG_NOT_FOUND', $payload['type']); + $this->assertEquals('TAG_NOT_FOUND', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Tag not found', $payload['title']); } } diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index b918a369..3526c5f5 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -16,18 +16,21 @@ class AuthenticationTest extends ApiTestCase /** @test */ public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void { + $expectedDetail = sprintf( + 'Expected one of the following authentication headers, but none were provided, ["%s"]', + implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) + ); + $resp = $this->callApi(self::METHOD_GET, '/short-codes'); - ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals('INVALID_AUTHORIZATION', $error); - $this->assertEquals( - sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', - implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) - ), - $message - ); + $this->assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); + $this->assertEquals('INVALID_AUTHORIZATION', $payload['type']); + $this->assertEquals('INVALID_AUTHORIZATION', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid authorization', $payload['title']); } /** @@ -36,16 +39,22 @@ class AuthenticationTest extends ApiTestCase */ public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(string $apiKey): void { + $expectedDetail = 'Provided API key does not exist or is invalid.'; + $resp = $this->callApi(self::METHOD_GET, '/short-codes', [ 'headers' => [ Plugin\ApiKeyHeaderPlugin::HEADER_NAME => $apiKey, ], ]); - ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals('INVALID_API_KEY', $error); - $this->assertEquals('Provided API key does not exist or is invalid.', $message); + $this->assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); + $this->assertEquals('INVALID_API_KEY', $payload['type']); + $this->assertEquals('INVALID_API_KEY', $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals('Invalid API key', $payload['title']); } public function provideInvalidApiKeys(): iterable @@ -61,19 +70,24 @@ class AuthenticationTest extends ApiTestCase */ public function authorizationErrorIsReturnedIfInvalidDataIsProvided( string $authValue, - string $expectedMessage, - string $expectedError + string $expectedDetail, + string $expectedType, + string $expectedTitle ): void { $resp = $this->callApi(self::METHOD_GET, '/short-codes', [ 'headers' => [ Plugin\AuthorizationHeaderPlugin::HEADER_NAME => $authValue, ], ]); - ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp); + $payload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals($expectedError, $error); - $this->assertEquals($expectedMessage, $message); + $this->assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); + $this->assertEquals($expectedType, $payload['type']); + $this->assertEquals($expectedType, $payload['error']); // Deprecated + $this->assertEquals($expectedDetail, $payload['detail']); + $this->assertEquals($expectedDetail, $payload['message']); // Deprecated + $this->assertEquals($expectedTitle, $payload['title']); } public function provideInvalidAuthorizations(): iterable @@ -82,17 +96,20 @@ class AuthenticationTest extends ApiTestCase 'invalid', 'You need to provide the Bearer type in the Authorization header.', 'INVALID_AUTHORIZATION', + 'Invalid authorization', ]; yield 'invalid type' => [ 'Basic invalid', 'Provided authorization type Basic is not supported. Use Bearer instead.', 'INVALID_AUTHORIZATION', + 'Invalid authorization', ]; yield 'invalid JWT' => [ 'Bearer invalid', 'Missing or invalid auth token provided. Perform a new authentication request and send provided ' . 'token on every new request on the Authorization header', 'INVALID_AUTH_TOKEN', + 'Invalid auth token', ]; } } From 5055ddf995717f88eb9b201ad857b5e0f09cfb7d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Nov 2019 18:42:27 +0100 Subject: [PATCH 037/100] Updated CLI commands to just print exception messages when possible --- .../CLI/src/Command/Api/DisableKeyCommand.php | 4 +- .../ShortUrl/DeleteShortUrlCommand.php | 13 ++---- .../ShortUrl/GenerateShortUrlCommand.php | 9 +--- .../Command/ShortUrl/ResolveUrlCommand.php | 2 +- .../CLI/src/Command/Tag/RenameTagCommand.php | 4 +- .../GeolocationDbUpdateFailedException.php | 12 ++---- module/CLI/src/Util/GeolocationDbUpdater.php | 3 -- .../Command/Api/DisableKeyCommandTest.php | 9 ++-- .../ShortUrl/DeleteShortUrlCommandTest.php | 14 +++---- .../ShortUrl/GenerateShortUrlCommandTest.php | 9 ++-- .../ShortUrl/ResolveUrlCommandTest.php | 7 ++-- .../test/Command/Tag/RenameTagCommandTest.php | 8 ++-- ...GeolocationDbUpdateFailedExceptionTest.php | 41 ------------------- .../src/Exception/NonUniqueSlugException.php | 2 +- 14 files changed, 40 insertions(+), 97 deletions(-) diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index e0f2b79c..f3eb7f6b 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use InvalidArgumentException; use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -45,7 +45,7 @@ class DisableKeyCommand extends Command $io->success(sprintf('API key "%s" properly disabled', $apiKey)); return ExitCodes::EXIT_SUCCESS; } catch (InvalidArgumentException $e) { - $io->error(sprintf('API key "%s" does not exist.', $apiKey)); + $io->error($e->getMessage()); return ExitCodes::EXIT_FAILURE; } } diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index f44e3e18..c2e81d0d 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -56,21 +56,16 @@ class DeleteShortUrlCommand extends Command $this->runDelete($io, $shortCode, $ignoreThreshold); return ExitCodes::EXIT_SUCCESS; } catch (Exception\ShortUrlNotFoundException $e) { - $io->error(sprintf('Provided short code "%s" could not be found.', $shortCode)); + $io->error($e->getMessage()); return ExitCodes::EXIT_FAILURE; } catch (Exception\DeleteShortUrlException $e) { - return $this->retry($io, $shortCode, $e); + return $this->retry($io, $shortCode, $e->getMessage()); } } - private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): int + private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int { - $warningMsg = sprintf( - 'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.', - $shortCode, - $e->getVisitsThreshold() - ); - $io->writeln('' . $warningMsg . ''); + $io->writeln(sprintf('%s', $warningMsg)); $forceDelete = $io->confirm('Do you want to delete it anyway?', false); if ($forceDelete) { diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 9d9c464b..5cce7030 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -141,13 +141,8 @@ class GenerateShortUrlCommand extends Command sprintf('Generated short URL: %s', $shortUrl->toString($this->domainConfig)), ]); return ExitCodes::EXIT_SUCCESS; - } catch (InvalidUrlException $e) { - $io->error(sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl)); - return ExitCodes::EXIT_FAILURE; - } catch (NonUniqueSlugException $e) { - $io->error( - sprintf('Provided slug "%s" is already in use by another URL. Try with a different one.', $customSlug) - ); + } catch (InvalidUrlException | NonUniqueSlugException $e) { + $io->error($e->getMessage()); return ExitCodes::EXIT_FAILURE; } } diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index 28564369..e8db28e2 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -65,7 +65,7 @@ class ResolveUrlCommand extends Command $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return ExitCodes::EXIT_SUCCESS; } catch (ShortUrlNotFoundException $e) { - $io->error(sprintf('Provided short code "%s" could not be found.', $shortCode)); + $io->error($e->getMessage()); return ExitCodes::EXIT_FAILURE; } } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index a7002f60..b3a21a2e 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -13,8 +13,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function sprintf; - class RenameTagCommand extends Command { public const NAME = 'tag:rename'; @@ -48,7 +46,7 @@ class RenameTagCommand extends Command $io->success('Tag properly renamed.'); return ExitCodes::EXIT_SUCCESS; } catch (TagNotFoundException $e) { - $io->error(sprintf('A tag with name "%s" was not found', $oldName)); + $io->error($e->getMessage()); return ExitCodes::EXIT_FAILURE; } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index fcb680d5..38bb4c5f 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -12,20 +12,16 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc /** @var bool */ private $olderDbExists; - public function __construct(bool $olderDbExists, string $message = '', int $code = 0, ?Throwable $previous = null) - { - $this->olderDbExists = $olderDbExists; - parent::__construct($message, $code, $previous); - } - public static function create(bool $olderDbExists, ?Throwable $prev = null): self { - return new self( - $olderDbExists, + $e = new self( 'An error occurred while updating geolocation database, and an older version could not be found', 0, $prev ); + $e->olderDbExists = $olderDbExists; + + return $e; } public function olderDbExists(): bool diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index ebff82aa..2e530a34 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -10,7 +10,6 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Symfony\Component\Lock\Factory as Locker; -use Throwable; class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { @@ -40,8 +39,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface try { $this->downloadIfNeeded($mustBeUpdated, $handleProgress); - } catch (Throwable $e) { - throw $e; } finally { $lock->release(); } diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index ca17aa7a..37629091 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -29,7 +29,7 @@ class DisableKeyCommandTest extends TestCase } /** @test */ - public function providedApiKeyIsDisabled() + public function providedApiKeyIsDisabled(): void { $apiKey = 'abcd1234'; $this->apiKeyService->disable($apiKey)->shouldBeCalledOnce(); @@ -43,17 +43,18 @@ class DisableKeyCommandTest extends TestCase } /** @test */ - public function errorIsReturnedIfServiceThrowsException() + public function errorIsReturnedIfServiceThrowsException(): void { $apiKey = 'abcd1234'; - $disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class); + $expectedMessage = 'API key "abcd1234" does not exist.'; + $disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage)); $this->commandTester->execute([ 'apiKey' => $apiKey, ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('API key "abcd1234" does not exist.', $output); + $this->assertStringContainsString($expectedMessage, $output); $disable->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 6a036c62..85521835 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -58,13 +58,13 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( - Exception\ShortUrlNotFoundException::class + Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode) ); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf('Provided short code "%s" could not be found.', $shortCode), $output); + $this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); $deleteByShortCode->shouldHaveBeenCalledOnce(); } @@ -79,11 +79,11 @@ class DeleteShortUrlCommandTest extends TestCase ): void { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will( - function (array $args) { + function (array $args) use ($shortCode) { $ignoreThreshold = array_pop($args); if (!$ignoreThreshold) { - throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, ''); + throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode); } } ); @@ -93,7 +93,7 @@ class DeleteShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); $this->assertStringContainsString(sprintf( - 'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.', + 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', $shortCode ), $output); $this->assertStringContainsString($expectedMessage, $output); @@ -112,7 +112,7 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( - Exception\DeleteShortUrlException::fromVisitsThreshold(10, '') + Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode) ); $this->commandTester->setInputs(['no']); @@ -120,7 +120,7 @@ class DeleteShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); $this->assertStringContainsString(sprintf( - 'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.', + 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', $shortCode ), $output); $this->assertStringContainsString('Short URL was not deleted.', $output); diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index d83bd042..abae0fe6 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -59,21 +59,22 @@ class GenerateShortUrlCommandTest extends TestCase /** @test */ public function exceptionWhileParsingLongUrlOutputsError(): void { - $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException()) + $url = 'http://domain.com/invalid'; + $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url)) ->shouldBeCalledOnce(); - $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']); + $this->commandTester->execute(['longUrl' => $url]); $output = $this->commandTester->getDisplay(); $this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); - $this->assertStringContainsString('Provided URL "http://domain.com/invalid" is invalid.', $output); + $this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); } /** @test */ public function providingNonUniqueSlugOutputsError(): void { $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow( - NonUniqueSlugException::class + NonUniqueSlugException::fromSlug('my-slug') ); $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']); diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 23b4ec28..11b549e5 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -52,11 +52,12 @@ class ResolveUrlCommandTest extends TestCase public function incorrectShortCodeOutputsErrorMessage(): void { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(ShortUrlNotFoundException::class) - ->shouldBeCalledOnce(); + $this->urlShortener->shortCodeToUrl($shortCode, null) + ->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode)) + ->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf('Provided short code "%s" could not be found', $shortCode), $output); + $this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); } } diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 4fc2aaad..c626e0c0 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -34,11 +34,11 @@ class RenameTagCommandTest extends TestCase } /** @test */ - public function errorIsPrintedIfExceptionIsThrown() + public function errorIsPrintedIfExceptionIsThrown(): void { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::class); + $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo')); $this->commandTester->execute([ 'oldName' => $oldName, @@ -46,12 +46,12 @@ class RenameTagCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('A tag with name "foo" was not found', $output); + $this->assertStringContainsString('Tag with name "foo" could not be found', $output); $renameTag->shouldHaveBeenCalled(); } /** @test */ - public function successIsPrintedIfNoErrorOccurs() + public function successIsPrintedIfNoErrorOccurs(): void { $oldName = 'foo'; $newName = 'bar'; diff --git a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php index 51c87cb3..70a8cc6f 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -12,47 +12,6 @@ use Throwable; class GeolocationDbUpdateFailedExceptionTest extends TestCase { - /** - * @test - * @dataProvider provideOlderDbExists - */ - public function constructCreatesExceptionWithDefaultArgs(bool $olderDbExists): void - { - $e = new GeolocationDbUpdateFailedException($olderDbExists); - - $this->assertEquals($olderDbExists, $e->olderDbExists()); - $this->assertEquals('', $e->getMessage()); - $this->assertEquals(0, $e->getCode()); - $this->assertNull($e->getPrevious()); - } - - public function provideOlderDbExists(): iterable - { - yield 'with older DB' => [true]; - yield 'without older DB' => [false]; - } - - /** - * @test - * @dataProvider provideConstructorArgs - */ - public function constructCreatesException(bool $olderDbExists, string $message, int $code, ?Throwable $prev): void - { - $e = new GeolocationDbUpdateFailedException($olderDbExists, $message, $code, $prev); - - $this->assertEquals($olderDbExists, $e->olderDbExists()); - $this->assertEquals($message, $e->getMessage()); - $this->assertEquals($code, $e->getCode()); - $this->assertEquals($prev, $e->getPrevious()); - } - - public function provideConstructorArgs(): iterable - { - yield [true, 'This is a nice error message', 99, new Exception('prev')]; - yield [false, 'Another message', 0, new RuntimeException('prev')]; - yield [true, 'An yet another message', -50, null]; - } - /** * @test * @dataProvider provideCreateArgs diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index bcd40268..51beff82 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -17,7 +17,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem private const TITLE = 'Invalid custom slug'; private const TYPE = 'INVALID_SLUG'; - public static function fromSlug(string $slug, ?string $domain): self + public static function fromSlug(string $slug, ?string $domain = null): self { $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix)); From 60d3c09da53d409a800e5cabdced80ab83c43876 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 28 Nov 2019 19:37:22 +0100 Subject: [PATCH 038/100] Updated API docs to reference the use of application/problem+json --- docs/swagger/definitions/Error.json | 23 +++++++++- .../definitions/InvalidShortUrlMeta.json | 6 +++ docs/swagger/paths/v1_short-urls.json | 31 +++++++++++--- docs/swagger/paths/v1_short-urls_shorten.json | 15 ++++--- .../paths/v1_short-urls_{shortCode}.json | 42 ++++++++----------- .../paths/v1_short-urls_{shortCode}_tags.json | 2 +- .../v1_short-urls_{shortCode}_visits.json | 4 +- docs/swagger/paths/v1_tags.json | 12 +++--- docs/swagger/paths/{shortCode}.json | 10 ----- docs/swagger/paths/{shortCode}_preview.json | 10 ----- docs/swagger/paths/{shortCode}_qr-code.json | 10 ----- docs/swagger/paths/{shortCode}_track.json | 10 ----- docs/swagger/swagger.json | 12 +++--- 13 files changed, 94 insertions(+), 93 deletions(-) create mode 100644 docs/swagger/definitions/InvalidShortUrlMeta.json diff --git a/docs/swagger/definitions/Error.json b/docs/swagger/definitions/Error.json index 3585227d..006fa47a 100644 --- a/docs/swagger/definitions/Error.json +++ b/docs/swagger/definitions/Error.json @@ -1,13 +1,32 @@ { "type": "object", + "required": ["type", "title", "detail", "status"], "properties": { - "code": { + "type": { "type": "string", "description": "A machine unique code" }, + "title": { + "type": "string", + "description": "A unique title" + }, + "detail": { + "type": "string", + "description": "A human-friendly error description" + }, + "status": { + "type": "number", + "description": "HTTP response status code" + }, + "code": { + "type": "string", + "description": "**[Deprecated] Use type instead. Not returned for v2 of the REST API** A machine unique code", + "deprecated": true + }, "message": { "type": "string", - "description": "A human-friendly error message" + "description": "**[Deprecated] Use detail instead. Not returned for v2 of the REST API** A human-friendly error message", + "deprecated": true } } } diff --git a/docs/swagger/definitions/InvalidShortUrlMeta.json b/docs/swagger/definitions/InvalidShortUrlMeta.json new file mode 100644 index 00000000..ebe1fa34 --- /dev/null +++ b/docs/swagger/definitions/InvalidShortUrlMeta.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "properties": { + + } +} diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index f8fa1ec8..07ed36cc 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -150,7 +150,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -256,11 +256,32 @@ } }, "400": { - "description": "The long URL was not provided or is invalid.", + "description": "Some of provided data is invalid. Check extra fields to know exactly what.", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "type": "object", + "allOf": [ + { + "$ref": "./Error.json" + }, + { + "type": "object", + "properties": { + "invalidElements": { + "$ref": "./InvalidShortUrlMeta.json" + }, + "url": { + "type": "string", + "description": "A URL that could not be verified, if the error type is INVALID_URL" + }, + "customSlug": { + "type": "string", + "description": "Provided custom slug when the error type is INVALID_SLUG" + } + } + } + ] } } } @@ -268,7 +289,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index d0c3c4c7..d1887ddd 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -77,7 +77,7 @@ "400": { "description": "The long URL was not provided or is invalid.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -89,9 +89,12 @@ } }, "examples": { - "application/json": { - "error": "INVALID_URL", - "message": "Provided URL foo is invalid. Try with a different one." + "application/problem+json": { + "title": "Invalid URL", + "type": "INVALID_URL", + "detail": "Provided URL foo is invalid. Try with a different one.", + "status": 400, + "url": "https://invalid-url.com" }, "text/plain": "INVALID_URL" } @@ -99,7 +102,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -111,7 +114,7 @@ } }, "examples": { - "application/json": { + "application/problem+json": { "error": "INTERNAL_SERVER_ERROR", "message": "Unexpected error occurred" }, diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index bbb7145c..0a5cbed7 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -62,20 +62,10 @@ } } }, - "400": { - "description": "Provided shortCode does not match the character set currently used by the app to generate short codes.", - "content": { - "application/json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } - }, "404": { "description": "No URL was found for provided short code.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -85,7 +75,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -153,7 +143,7 @@ "400": { "description": "Provided meta arguments are invalid.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -163,7 +153,7 @@ "404": { "description": "No short URL was found for provided short code.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -173,7 +163,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -242,7 +232,7 @@ "400": { "description": "Provided meta arguments are invalid.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -252,7 +242,7 @@ "404": { "description": "No short URL was found for provided short code.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -262,7 +252,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -302,26 +292,28 @@ "204": { "description": "The short URL has been properly deleted." }, - "400": { + "422": { "description": "The visits threshold in shlink does not allow this short URL to be deleted.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } } }, "examples": { - "application/json": { - "error": "INVALID_SHORTCODE_DELETION", - "message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits." + "application/problem+json": { + "title": "Cannot delete short URL", + "type": "INVALID_SHORTCODE_DELETION", + "detail": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits.", + "status": 422 } } }, "404": { "description": "No short URL was found for provided short code.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -331,7 +323,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json index ab05d230..de3d0c9b 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json @@ -78,7 +78,7 @@ "400": { "description": "The request body does not contain a \"tags\" param with array type.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index a3cf5f10..d5dd243c 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -132,7 +132,7 @@ "404": { "description": "The short code does not belong to any short URL.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -142,7 +142,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 5bf260bb..faa83ed9 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -53,7 +53,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -140,7 +140,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -197,7 +197,7 @@ "400": { "description": "You have not provided either the oldName or the newName params.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -207,7 +207,7 @@ "404": { "description": "There's no tag found with the name provided in oldName param.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -217,7 +217,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } @@ -263,7 +263,7 @@ "500": { "description": "Unexpected error.", "content": { - "application/json": { + "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" } diff --git a/docs/swagger/paths/{shortCode}.json b/docs/swagger/paths/{shortCode}.json index eccd5ba1..bbebacbd 100644 --- a/docs/swagger/paths/{shortCode}.json +++ b/docs/swagger/paths/{shortCode}.json @@ -20,16 +20,6 @@ "responses": { "302": { "description": "Visit properly tracked and redirected" - }, - "500": { - "description": "Unexpected error.", - "content": { - "application/json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } } } } diff --git a/docs/swagger/paths/{shortCode}_preview.json b/docs/swagger/paths/{shortCode}_preview.json index f6168b4d..98df559c 100644 --- a/docs/swagger/paths/{shortCode}_preview.json +++ b/docs/swagger/paths/{shortCode}_preview.json @@ -29,16 +29,6 @@ } } } - }, - "500": { - "description": "Unexpected error.", - "content": { - "application/json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } } } } diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 14a8fddc..300a7429 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -40,16 +40,6 @@ } } } - }, - "500": { - "description": "Unexpected error.", - "content": { - "application/json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } } } } diff --git a/docs/swagger/paths/{shortCode}_track.json b/docs/swagger/paths/{shortCode}_track.json index b4b62bba..50f6bc5e 100644 --- a/docs/swagger/paths/{shortCode}_track.json +++ b/docs/swagger/paths/{shortCode}_track.json @@ -28,16 +28,6 @@ } } } - }, - "500": { - "description": "Unexpected error.", - "content": { - "application/json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } } } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 6dd3ff8f..2d0cb1c1 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -71,24 +71,24 @@ ], "paths": { - "/rest/v1/short-urls": { + "/rest/v2/short-urls": { "$ref": "paths/v1_short-urls.json" }, - "/rest/v1/short-urls/shorten": { + "/rest/v2/short-urls/shorten": { "$ref": "paths/v1_short-urls_shorten.json" }, - "/rest/v1/short-urls/{shortCode}": { + "/rest/v2/short-urls/{shortCode}": { "$ref": "paths/v1_short-urls_{shortCode}.json" }, - "/rest/v1/short-urls/{shortCode}/tags": { + "/rest/v2/short-urls/{shortCode}/tags": { "$ref": "paths/v1_short-urls_{shortCode}_tags.json" }, - "/rest/v1/tags": { + "/rest/v2/tags": { "$ref": "paths/v1_tags.json" }, - "/rest/v1/short-urls/{shortCode}/visits": { + "/rest/v2/short-urls/{shortCode}/visits": { "$ref": "paths/v1_short-urls_{shortCode}_visits.json" }, From 3cf1657d54b6d9d2282ef6d6403cf5f6211f081e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 29 Nov 2019 18:55:27 +0100 Subject: [PATCH 039/100] Simplified invalidElements to be a plain list of keys when a ValidationException is cast into a problem details error --- module/Core/src/Exception/ValidationException.php | 9 +++++++-- module/Core/test/Exception/ValidationExceptionTest.php | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index f3a5c515..abceec91 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -10,6 +10,7 @@ use Zend\InputFilter\InputFilterInterface; use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use function array_keys; use function Functional\reduce_left; use function is_array; use function print_r; @@ -24,6 +25,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet private const TITLE = 'Invalid data'; private const TYPE = 'INVALID_ARGUMENT'; + /** @var array */ + private $invalidElements; + public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self { return static::fromArray($inputFilter->getMessages(), $prev); @@ -38,14 +42,15 @@ class ValidationException extends InvalidArgumentException implements ProblemDet $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; - $e->additional = ['invalidElements' => $invalidData]; + $e->invalidElements = $invalidData; + $e->additional = ['invalidElements' => array_keys($invalidData)]; return $e; } public function getInvalidElements(): array { - return $this->additional['invalidElements']; + return $this->invalidElements; } public function __toString(): string diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 069506b5..11bb8026 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Throwable; use Zend\InputFilter\InputFilterInterface; +use function array_keys; use function print_r; class ValidationExceptionTest extends TestCase @@ -38,6 +39,7 @@ EOT; $e = ValidationException::fromInputFilter($inputFilter->reveal()); $this->assertEquals($invalidData, $e->getInvalidElements()); + $this->assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData()); $this->assertEquals('Provided data is not valid', $e->getMessage()); $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); $this->assertEquals($prev, $e->getPrevious()); From 4685572deffa3d4f59d5dac0321fc76d0803e8da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 29 Nov 2019 19:09:03 +0100 Subject: [PATCH 040/100] Added version param to endpoints --- .../definitions/InvalidShortUrlMeta.json | 6 ---- docs/swagger/parameters/version.json | 13 +++++++ docs/swagger/paths/v1_short-urls.json | 21 ++++++++++- docs/swagger/paths/v1_short-urls_shorten.json | 3 ++ .../paths/v1_short-urls_{shortCode}.json | 36 ++++++++++++++++++- .../paths/v1_short-urls_{shortCode}_tags.json | 3 ++ .../v1_short-urls_{shortCode}_visits.json | 3 ++ docs/swagger/paths/v1_tags.json | 18 ++++++++++ docs/swagger/swagger.json | 12 +++---- 9 files changed, 101 insertions(+), 14 deletions(-) delete mode 100644 docs/swagger/definitions/InvalidShortUrlMeta.json create mode 100644 docs/swagger/parameters/version.json diff --git a/docs/swagger/definitions/InvalidShortUrlMeta.json b/docs/swagger/definitions/InvalidShortUrlMeta.json deleted file mode 100644 index ebe1fa34..00000000 --- a/docs/swagger/definitions/InvalidShortUrlMeta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "object", - "properties": { - - } -} diff --git a/docs/swagger/parameters/version.json b/docs/swagger/parameters/version.json new file mode 100644 index 00000000..c2b1cc1a --- /dev/null +++ b/docs/swagger/parameters/version.json @@ -0,0 +1,13 @@ +{ + "name": "version", + "description": "The API version to be consumed", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "2", + "1" + ] + } +} diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 07ed36cc..a8bf0368 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -7,6 +7,9 @@ "summary": "List short URLs", "description": "Returns the list of short URLs.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "page", "in": "query", @@ -175,6 +178,11 @@ "Bearer": [] } ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], "requestBody": { "description": "Request body.", "required": true, @@ -269,7 +277,18 @@ "type": "object", "properties": { "invalidElements": { - "$ref": "./InvalidShortUrlMeta.json" + "type": "array", + "items": { + "type": "string", + "enum": [ + "validSince", + "validUntil", + "customSlug", + "maxVisits", + "findIfExists", + "domain" + ] + } }, "url": { "type": "string", diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index d1887ddd..49233c49 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -7,6 +7,9 @@ "summary": "Create a short URL", "description": "Creates a short URL in a single API call. Useful for third party integrations.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "apiKey", "in": "query", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 0a5cbed7..1588d71c 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -7,6 +7,9 @@ "summary": "Parse short code", "description": "Get the long URL behind a short URL's short code.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", @@ -93,6 +96,9 @@ "summary": "Edit short URL", "description": "Update certain meta arguments from an existing short URL.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", @@ -145,7 +151,29 @@ "content": { "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "type": "object", + "allOf": [ + { + "$ref": "./Error.json" + }, + { + "type": "object", + "required": ["invalidElements"], + "properties": { + "invalidElements": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "validSince", + "validUntil", + "maxVisits" + ] + } + } + } + } + ] } } } @@ -182,6 +210,9 @@ "summary": "[DEPRECATED] Edit short URL", "description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", @@ -270,6 +301,9 @@ "summary": "Delete short URL", "description": "Deletes the short URL for provided short code.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json index de3d0c9b..45142e62 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json @@ -7,6 +7,9 @@ "summary": "Edit tags on short URL", "description": "Edit the tags on URL identified by provided short code.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index d5dd243c..508449de 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -7,6 +7,9 @@ "summary": "List visits for short URL", "description": "Get the list of visits on the short URL behind provided short code.

**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "shortCode", "in": "path", diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index faa83ed9..da0159bd 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -14,6 +14,11 @@ "Bearer": [] } ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], "responses": { "200": { "description": "The list of tags", @@ -78,6 +83,11 @@ "Bearer": [] } ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], "requestBody": { "description": "Request body.", "required": true, @@ -165,6 +175,11 @@ "Bearer": [] } ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], "requestBody": { "description": "Request body.", "required": true, @@ -235,6 +250,9 @@ "summary": "Delete tags", "description": "Deletes provided list of tags", "parameters": [ + { + "$ref": "../parameters/version.json" + }, { "name": "tags[]", "in": "query", diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 2d0cb1c1..d924b8a0 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -71,24 +71,24 @@ ], "paths": { - "/rest/v2/short-urls": { + "/rest/v{version}/short-urls": { "$ref": "paths/v1_short-urls.json" }, - "/rest/v2/short-urls/shorten": { + "/rest/v{version}/short-urls/shorten": { "$ref": "paths/v1_short-urls_shorten.json" }, - "/rest/v2/short-urls/{shortCode}": { + "/rest/v{version}/short-urls/{shortCode}": { "$ref": "paths/v1_short-urls_{shortCode}.json" }, - "/rest/v2/short-urls/{shortCode}/tags": { + "/rest/v{version}/short-urls/{shortCode}/tags": { "$ref": "paths/v1_short-urls_{shortCode}_tags.json" }, - "/rest/v2/tags": { + "/rest/v{version}/tags": { "$ref": "paths/v1_tags.json" }, - "/rest/v2/short-urls/{shortCode}/visits": { + "/rest/v{version}/short-urls/{shortCode}/visits": { "$ref": "paths/v1_short-urls_{shortCode}_visits.json" }, From 6c37905c1582a1a26bdb7a6d40944159eba078f0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 29 Nov 2019 19:24:04 +0100 Subject: [PATCH 041/100] Fixed cross-domain headers being lost when ProblemDetailsMiddleware throws an error --- config/autoload/middleware-pipeline.global.php | 2 +- docs/swagger/paths/v1_short-urls.json | 2 +- docs/swagger/paths/v1_short-urls_{shortCode}.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index f3370a7a..35a9a16b 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -20,6 +20,7 @@ return [ 'error-handler-rest' => [ 'path' => '/rest', 'middleware' => [ + Rest\Middleware\CrossDomainMiddleware::class, Rest\Middleware\BackwardsCompatibleProblemDetailsMiddleware::class, ProblemDetails\ProblemDetailsMiddleware::class, ], @@ -47,7 +48,6 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ - Rest\Middleware\CrossDomainMiddleware::class, Expressive\Router\Middleware\ImplicitOptionsMiddleware::class, Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class, diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index a8bf0368..d0aebfec 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -271,7 +271,7 @@ "type": "object", "allOf": [ { - "$ref": "./Error.json" + "$ref": "../definitions/Error.json" }, { "type": "object", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 1588d71c..95532868 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -154,7 +154,7 @@ "type": "object", "allOf": [ { - "$ref": "./Error.json" + "$ref": "../definitions/Error.json" }, { "type": "object", From 4401824716e6c449f5f6c1d9ac1d11ad54657e57 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 29 Nov 2019 19:43:34 +0100 Subject: [PATCH 042/100] Updated changelog --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283fdbe5..f17ae4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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 + +* [#118](https://github.com/shlinkio/shlink/issues/118) API errors now implement the [problem details](https://tools.ietf.org/html/rfc7807) standard. + + In order to make it backwards compatible, two things have been done: + + * Both the old `error` and `message` properties have been kept on error response, containing the same values as the `type` and `detail` properties respectively. + * The API `v2` has been enabled. If an error occurs when calling the API with this version, the `error` and `message` properties will not be returned. + + > After Shlink v2 is released, both API versions will behave like API v2. + +#### Changed + +* *Nothing* + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* *Nothing* + + ## 1.20.1 - 2019-11-17 #### Added From 5d76a55c4666cea7758ea076085bb92784304408 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 09:28:09 +0100 Subject: [PATCH 043/100] Updated how monolog handlers are registered so that it is possible to overwrite them via local config --- config/autoload/logger.global.php | 5 ++++- config/autoload/logger.local.php.dist | 8 ++++---- docker/config/shlink_in_docker.local.php | 7 +++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 6b0df063..e612d2f4 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -48,7 +48,10 @@ return [ 'loggers' => [ 'Shlink' => [ - 'handlers' => ['shlink_rotating_handler'], + 'handlers' => [ + // Using a key allows for this to be overwritten + 'shlink_handler' => 'shlink_rotating_handler', + ], 'processors' => ['exception_with_new_line', 'psr3'], ], 'Access' => [ diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index cf7e4801..403a26fa 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -1,4 +1,5 @@ [ - 'shlink_rotating_handler' => [ - 'level' => Logger::EMERGENCY, // This basically disables regular file logs - ], 'shlink_stdout_handler' => [ 'class' => StreamHandler::class, 'level' => Logger::DEBUG, @@ -22,7 +20,9 @@ $logger = $isSwoole ? [ 'loggers' => [ 'Shlink' => [ - 'handlers' => ['shlink_stdout_handler'], + 'handlers' => [ + 'shlink_handler' => 'shlink_stdout_handler', + ], ], ], ] : [ diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index bd2d0411..176e45f5 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -131,9 +131,6 @@ return [ 'logger' => [ 'handlers' => [ - 'shlink_rotating_handler' => [ - 'level' => Logger::EMERGENCY, // This basically disables regular file logs - ], 'shlink_stdout_handler' => [ 'class' => StreamHandler::class, 'level' => Logger::INFO, @@ -144,7 +141,9 @@ return [ 'loggers' => [ 'Shlink' => [ - 'handlers' => ['shlink_stdout_handler'], + 'handlers' => [ + 'shlink_handler' => 'shlink_stdout_handler', + ], ], ], ], From e558bb17cb4145c30ad66cd5935e5ed9977f370a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 17:21:36 +0100 Subject: [PATCH 044/100] Updated dependencies --- .travis.yml | 7 +--- composer.json | 40 ++++++++----------- config/autoload/locks.global.php | 8 ++-- module/CLI/config/dependencies.config.php | 8 ++-- .../Command/Db/AbstractDatabaseCommand.php | 4 +- .../src/Command/Db/CreateDatabaseCommand.php | 4 +- .../ShortUrl/GeneratePreviewCommand.php | 2 +- .../Command/Util/AbstractLockedCommand.php | 6 +-- .../src/Command/Visit/LocateVisitsCommand.php | 8 ++-- .../CLI/src/Command/Visit/UpdateDbCommand.php | 2 +- module/CLI/src/Util/GeolocationDbUpdater.php | 6 +-- 11 files changed, 42 insertions(+), 53 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6b2a861a..19aece15 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,10 +9,6 @@ php: - '7.3' - '7.4snapshot' -matrix: - allow_failures: - - php: '7.4snapshot' - services: - mysql - postgresql @@ -43,7 +39,8 @@ script: after_success: - rm -f build/clover.xml - - phpdbg -qrr vendor/bin/phpcov merge build --clover build/clover.xml + - wget https://phar.phpunit.de/phpcov-6.0.1.phar + - phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml diff --git a/composer.json b/composer.json index 5d035b60..fd3840bb 100644 --- a/composer.json +++ b/composer.json @@ -28,20 +28,20 @@ "guzzlehttp/guzzle": "^6.3", "lstrojny/functional-php": "^1.9", "mikehaertl/phpwkhtmltopdf": "^2.2", - "monolog/monolog": "^1.24", + "monolog/monolog": "^2.0", + "nikolaposa/monolog-factory": "^3.0", "ocramius/proxy-manager": "~2.2.2", "phly/phly-event-dispatcher": "^1.0", "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", - "shlinkio/shlink-common": "^2.3", - "shlinkio/shlink-event-dispatcher": "^1.0", - "shlinkio/shlink-installer": "^3.1", - "shlinkio/shlink-ip-geolocation": "^1.1", - "symfony/console": "^4.3", - "symfony/filesystem": "^4.3", - "symfony/lock": "^4.3", - "symfony/process": "^4.3", - "theorchard/monolog-cascade": "^0.5", + "shlinkio/shlink-common": "^2.4", + "shlinkio/shlink-event-dispatcher": "^1.1", + "shlinkio/shlink-installer": "^3.2", + "shlinkio/shlink-ip-geolocation": "^1.2", + "symfony/console": "^5.0", + "symfony/filesystem": "^5.0", + "symfony/lock": "^5.0", + "symfony/process": "^5.0", "zendframework/zend-config": "^3.3", "zendframework/zend-config-aggregator": "^1.1", "zendframework/zend-diactoros": "^2.1.3", @@ -59,17 +59,14 @@ "require-dev": { "devster/ubench": "^2.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.14.2", - "phpstan/phpstan": "^0.11.16", - "phpunit/phpcov": "^6.0", + "infection/infection": "^0.15.0", + "phpstan/phpstan-shim": "^0.11.16", "phpunit/phpunit": "^8.3", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.0.0", - "shlinkio/shlink-test-utils": "^1.1", - "symfony/dotenv": "^4.3", - "symfony/var-dumper": "^4.3", - "zendframework/zend-component-installer": "^2.1", - "zendframework/zend-expressive-tooling": "^1.2" + "shlinkio/shlink-test-utils": "^1.2", + "symfony/dotenv": "^5.0", + "symfony/var-dumper": "^5.0" }, "autoload": { "psr-4": { @@ -115,7 +112,7 @@ "@test:api" ], "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", - "test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml --testdox", + "test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml --testdox", "test:db": [ "@test:db:sqlite", "@test:db:mysql", @@ -132,10 +129,6 @@ "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:pretty": [ - "@test", - "phpdbg -qrr vendor/bin/phpcov merge build --html build/html" - ], "test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage", "infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered", "infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build", @@ -162,7 +155,6 @@ "test:db:maria": "Runs database test suites on a MariaDB database", "test:db:postgres": "Runs database test suites on a PostgreSQL database", "test:api": "Runs API test suites", - "test:pretty": "Runs all test suites and generates an HTML code coverage report", "test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report", "infect": "Checks unit tests quality applying mutation testing", "infect:ci": "Checks unit tests quality applying mutation testing with existing reports and logs", diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index db64e187..e8c39338 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -10,7 +10,7 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name $localLockFactory = 'Shlinkio\Shlink\LocalLockFactory'; -class_alias(Lock\Factory::class, $localLockFactory); +class_alias(Lock\LockFactory::class, $localLockFactory); return [ @@ -22,7 +22,7 @@ return [ 'factories' => [ Lock\Store\FlockStore::class => ConfigAbstractFactory::class, Lock\Store\RedisStore::class => ConfigAbstractFactory::class, - Lock\Factory::class => ConfigAbstractFactory::class, + Lock\LockFactory::class => ConfigAbstractFactory::class, $localLockFactory => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -36,7 +36,7 @@ return [ Lock\Store\RedisStore::class => [ RetryLockStoreDelegatorFactory::class, ], - Lock\Factory::class => [ + Lock\LockFactory::class => [ LoggerAwareDelegatorFactory::class, ], ], @@ -45,7 +45,7 @@ return [ ConfigAbstractFactory::class => [ Lock\Store\FlockStore::class => ['config.locks.locks_dir'], Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME], - Lock\Factory::class => ['lock_store'], + Lock\LockFactory::class => ['lock_store'], $localLockFactory => ['local_lock_store'], ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index a7f7e90e..c89a0ad7 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Symfony\Component\Console as SymfonyCli; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Zend\ServiceManager\Factory\InvokableFactory; @@ -70,7 +70,7 @@ return [ Command\Visit\LocateVisitsCommand::class => [ Service\VisitService::class, IpLocationResolverInterface::class, - Locker::class, + LockFactory::class, GeolocationDbUpdater::class, ], Command\Visit\UpdateDbCommand::class => [DbUpdater::class], @@ -85,14 +85,14 @@ return [ Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class], Command\Db\CreateDatabaseCommand::class => [ - Locker::class, + LockFactory::class, SymfonyCli\Helper\ProcessHelper::class, PhpExecutableFinder::class, Connection::class, NoDbNameConnectionFactory::SERVICE_NAME, ], Command\Db\MigrateDatabaseCommand::class => [ - Locker::class, + LockFactory::class, SymfonyCli\Helper\ProcessHelper::class, PhpExecutableFinder::class, ], diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index 3ab12b3b..bf99de9b 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; use function array_unshift; @@ -20,7 +20,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand /** @var string */ private $phpBinary; - public function __construct(Locker $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder) + public function __construct(LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder) { parent::__construct($locker); $this->processHelper = $processHelper; diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 36bb9de4..54cd27ea 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; use function Functional\contains; @@ -27,7 +27,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private $noDbNameConn; public function __construct( - Locker $locker, + LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder, Connection $conn, diff --git a/module/CLI/src/Command/ShortUrl/GeneratePreviewCommand.php b/module/CLI/src/Command/ShortUrl/GeneratePreviewCommand.php index 7483e890..081fa94f 100644 --- a/module/CLI/src/Command/ShortUrl/GeneratePreviewCommand.php +++ b/module/CLI/src/Command/ShortUrl/GeneratePreviewCommand.php @@ -69,7 +69,7 @@ class GeneratePreviewCommand extends Command } catch (PreviewGenerationException $e) { $output->writeln(' Error'); if ($output->isVerbose()) { - $this->getApplication()->renderException($e, $output); + $this->getApplication()->renderThrowable($e, $output); } } } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index 0e206cbe..59ea74fa 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -8,16 +8,16 @@ use Shlinkio\Shlink\CLI\Util\ExitCodes; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use function sprintf; abstract class AbstractLockedCommand extends Command { - /** @var Locker */ + /** @var LockFactory */ private $locker; - public function __construct(Locker $locker) + public function __construct(LockFactory $locker) { parent::__construct(); $this->locker = $locker; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 21012480..710c4a3a 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -22,7 +22,7 @@ use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Throwable; use function sprintf; @@ -47,7 +47,7 @@ class LocateVisitsCommand extends AbstractLockedCommand public function __construct( VisitServiceInterface $visitService, IpLocationResolverInterface $ipLocationResolver, - Locker $locker, + LockFactory $locker, GeolocationDbUpdaterInterface $dbUpdater ) { parent::__construct($locker); @@ -87,7 +87,7 @@ class LocateVisitsCommand extends AbstractLockedCommand } catch (Throwable $e) { $this->io->error($e->getMessage()); if ($e instanceof Exception && $this->io->isVerbose()) { - $this->getApplication()->renderException($e, $this->io); + $this->getApplication()->renderThrowable($e, $this->io); } return ExitCodes::EXIT_FAILURE; @@ -116,7 +116,7 @@ class LocateVisitsCommand extends AbstractLockedCommand } catch (WrongIpException $e) { $this->io->writeln(' [An error occurred while locating IP. Skipped]'); if ($this->io->isVerbose()) { - $this->getApplication()->renderException($e, $this->io); + $this->getApplication()->renderThrowable($e, $this->io); } throw IpCannotBeLocatedException::forError($e); diff --git a/module/CLI/src/Command/Visit/UpdateDbCommand.php b/module/CLI/src/Command/Visit/UpdateDbCommand.php index c138372b..367423fd 100644 --- a/module/CLI/src/Command/Visit/UpdateDbCommand.php +++ b/module/CLI/src/Command/Visit/UpdateDbCommand.php @@ -84,7 +84,7 @@ class UpdateDbCommand extends Command $io->error($baseErrorMsg); if ($io->isVerbose()) { - $this->getApplication()->renderException($e, $io); + $this->getApplication()->renderThrowable($e, $io); } return ExitCodes::EXIT_FAILURE; } diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index 2e530a34..25b49c37 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -9,7 +9,7 @@ use GeoIp2\Database\Reader; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; -use Symfony\Component\Lock\Factory as Locker; +use Symfony\Component\Lock\LockFactory; class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { @@ -19,10 +19,10 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface private $dbUpdater; /** @var Reader */ private $geoLiteDbReader; - /** @var Locker */ + /** @var LockFactory */ private $locker; - public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, Locker $locker) + public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, LockFactory $locker) { $this->dbUpdater = $dbUpdater; $this->geoLiteDbReader = $geoLiteDbReader; From cf3d763731932bd26c66673380b347a11b8b8af6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 17:59:04 +0100 Subject: [PATCH 045/100] Replaced monolog-cascade by MonologFactory --- config/autoload/logger.global.php | 90 ++++++++++--------- config/autoload/logger.local.php.dist | 35 ++++---- docker/config/shlink_in_docker.local.php | 21 ++--- .../Command/Db/CreateDatabaseCommandTest.php | 4 +- .../Command/Db/MigrateDatabaseCommandTest.php | 4 +- .../Command/Visit/LocateVisitsCommandTest.php | 2 +- .../test/Util/GeolocationDbUpdaterTest.php | 2 +- 7 files changed, 75 insertions(+), 83 deletions(-) diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index e612d2f4..f0d66336 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -4,67 +4,69 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Monolog\Handler\RotatingFileHandler; -use Monolog\Handler\StreamHandler; +use Monolog\Formatter; +use Monolog\Handler; use Monolog\Logger; use Monolog\Processor; +use MonologFactory\DiContainerLoggerFactory; use Psr\Log\LoggerInterface; use const PHP_EOL; +$processors = [ + 'exception_with_new_line' => [ + 'name' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class, + ], + 'psr3' => [ + 'name' => Processor\PsrLogMessageProcessor::class, + ], +]; +$formatter = [ + 'name' => Formatter\LineFormatter::class, + 'params' => [ + 'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL, + 'include_stacktraces' => true, + ], +]; + return [ 'logger' => [ - 'formatters' => [ - 'dashed' => [ - 'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL, - 'include_stacktraces' => true, - ], - ], - - 'handlers' => [ - 'shlink_rotating_handler' => [ - 'class' => RotatingFileHandler::class, - 'level' => Logger::INFO, - 'filename' => 'data/log/shlink_log.log', - 'max_files' => 30, - 'formatter' => 'dashed', - ], - 'access_handler' => [ - 'class' => StreamHandler::class, - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - ], - ], - - 'processors' => [ - 'exception_with_new_line' => [ - 'class' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class, - ], - 'psr3' => [ - 'class' => Processor\PsrLogMessageProcessor::class, - ], - ], - - 'loggers' => [ - 'Shlink' => [ - 'handlers' => [ - // Using a key allows for this to be overwritten - 'shlink_handler' => 'shlink_rotating_handler', + 'Shlink' => [ + 'name' => 'Shlink', + 'handlers' => [ + 'shlink_handler' => [ + 'name' => Handler\RotatingFileHandler::class, + 'params' => [ + 'level' => Logger::INFO, + 'filename' => 'data/log/shlink_log.log', + 'max_files' => 30, + ], + 'formatter' => $formatter, ], - 'processors' => ['exception_with_new_line', 'psr3'], ], - 'Access' => [ - 'handlers' => ['access_handler'], - 'processors' => ['exception_with_new_line', 'psr3'], + 'processors' => $processors, + ], + 'Access' => [ + 'name' => 'Access', + 'handlers' => [ + 'access_handler' => [ + 'name' => Handler\StreamHandler::class, + 'params' => [ + 'level' => Logger::INFO, + 'stream' => 'php://stdout', + ], + 'formatter' => $formatter, + ], ], + 'processors' => $processors, ], ], 'dependencies' => [ 'factories' => [ - 'Logger_Shlink' => Common\Logger\LoggerFactory::class, - 'Logger_Access' => Common\Logger\LoggerFactory::class, + 'Logger_Shlink' => [DiContainerLoggerFactory::class, 'Shlink'], + 'Logger_Access' => [DiContainerLoggerFactory::class, 'Access'], ], 'aliases' => [ 'logger' => 'Logger_Shlink', diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index 403a26fa..4aa46c68 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -8,33 +8,28 @@ use Monolog\Logger; $isSwoole = extension_loaded('swoole'); // For swoole, send logs to standard output -$logger = $isSwoole ? [ - 'handlers' => [ - 'shlink_stdout_handler' => [ - 'class' => StreamHandler::class, +$handler = $isSwoole + ? [ + 'name' => StreamHandler::class, + 'params' => [ 'level' => Logger::DEBUG, 'stream' => 'php://stdout', - 'formatter' => 'dashed', ], - ], - - 'loggers' => [ - 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => 'shlink_stdout_handler', - ], - ], - ], -] : [ - 'handlers' => [ - 'shlink_rotating_handler' => [ + ] + : [ + 'params' => [ 'level' => Logger::DEBUG, ], - ], -]; + ]; return [ - 'logger' => $logger, + 'logger' => [ + 'Shlink' => [ + 'handlers' => [ + 'shlink_handler' => $handler, + ], + ], + ], ]; diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 176e45f5..5da6761d 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -130,19 +130,14 @@ return [ 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), 'logger' => [ - 'handlers' => [ - 'shlink_stdout_handler' => [ - 'class' => StreamHandler::class, - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - 'formatter' => 'dashed', - ], - ], - - 'loggers' => [ - 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => 'shlink_stdout_handler', + 'Shlink' => [ + 'handlers' => [ + 'shlink_handler' => [ + 'name' => StreamHandler::class, + 'params' => [ + 'level' => Logger::INFO, + 'stream' => 'php://stdout', + ], ], ], ], diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 89322544..7945ea05 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -15,7 +15,7 @@ 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\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; @@ -36,7 +36,7 @@ class CreateDatabaseCommandTest extends TestCase public function setUp(): void { - $locker = $this->prophesize(Locker::class); + $locker = $this->prophesize(LockFactory::class); $lock = $this->prophesize(LockInterface::class); $lock->acquire(Argument::any())->willReturn(true); $lock->release()->will(function () { diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 1e7690ae..be36a980 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -12,7 +12,7 @@ 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\Factory as Locker; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; @@ -25,7 +25,7 @@ class MigrateDatabaseCommandTest extends TestCase public function setUp(): void { - $locker = $this->prophesize(Locker::class); + $locker = $this->prophesize(LockFactory::class); $lock = $this->prophesize(LockInterface::class); $lock->acquire(Argument::any())->willReturn(true); $lock->release()->will(function () { diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index bb3be84c..e01bf85f 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -48,7 +48,7 @@ class LocateVisitsCommandTest extends TestCase $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); - $this->locker = $this->prophesize(Lock\Factory::class); + $this->locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); $this->lock->acquire(false)->willReturn(true); $this->lock->release()->will(function () { diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/Util/GeolocationDbUpdaterTest.php index 56819924..f2b0f98c 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/Util/GeolocationDbUpdaterTest.php @@ -38,7 +38,7 @@ class GeolocationDbUpdaterTest extends TestCase $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); $this->geoLiteDbReader = $this->prophesize(Reader::class); - $this->locker = $this->prophesize(Lock\Factory::class); + $this->locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); $this->lock->acquire(true)->willReturn(true); $this->lock->release()->will(function () { From 6bcdd5e6c8115535c5abaf0806e817a9b74301b4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 18:08:30 +0100 Subject: [PATCH 046/100] Cleaned last beats of to make everything BC --- config/autoload/logger.global.php | 2 +- config/container.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index f0d66336..40aa8a2d 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -25,7 +25,7 @@ $formatter = [ 'name' => Formatter\LineFormatter::class, 'params' => [ 'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL, - 'include_stacktraces' => true, + 'allow_inline_line_breaks' => true, ], ]; diff --git a/config/container.php b/config/container.php index 37be5669..f2c1d0c7 100644 --- a/config/container.php +++ b/config/container.php @@ -13,7 +13,7 @@ require 'vendor/autoload.php'; if (class_exists(Dotenv::class)) { error_reporting(E_ALL); ini_set('display_errors', '1'); - $dotenv = new Dotenv(); + $dotenv = new Dotenv(true); $dotenv->load(__DIR__ . '/../.env'); } From ed94ec39c4e86f8c6634c4ccd3e47449389960dc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 18:09:38 +0100 Subject: [PATCH 047/100] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17ae4d4..6394408b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this #### Changed -* *Nothing* +* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. #### Deprecated From 3080c49caf06ec6a1c4803f6586e73cdb8de36e3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 30 Nov 2019 18:15:09 +0100 Subject: [PATCH 048/100] Bringing back allowing failures on PHP 7.4 --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 19aece15..5385b1c3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,10 @@ php: - '7.3' - '7.4snapshot' +matrix: + allow_failures: + - php: '7.4snapshot' + services: - mysql - postgresql From 1bf56b658b2ebab356aa53f4319f91ee243302f3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 10:14:29 +0100 Subject: [PATCH 049/100] Improved domain exception tests to cover more possible mutants --- .../Exception/DeleteShortUrlExceptionTest.php | 8 ++++ .../Exception/InvalidUrlExceptionTest.php | 13 +++++- .../Exception/NonUniqueSlugExceptionTest.php | 11 +++++ .../ShortUrlNotFoundExceptionTest.php | 11 +++++ .../Exception/TagNotFoundExceptionTest.php | 28 ++++++++++++ .../MissingAuthenticationException.php | 2 +- .../Middleware/AuthenticationTest.php | 2 +- .../RequestToAuthPluginTest.php | 2 +- .../MissingAuthenticationExceptionTest.php | 43 +++++++++++++++++++ 9 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 module/Core/test/Exception/TagNotFoundExceptionTest.php create mode 100644 module/Rest/test/Exception/MissingAuthenticationExceptionTest.php diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index 0e2012a2..f2207c54 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -27,6 +27,14 @@ class DeleteShortUrlExceptionTest extends TestCase $this->assertEquals($threshold, $e->getVisitsThreshold()); $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals([ + 'shortCode' => $shortCode, + 'threshold' => $threshold, + ], $e->getAdditionalData()); + $this->assertEquals('Cannot delete short URL', $e->getTitle()); + $this->assertEquals('INVALID_SHORTCODE_DELETION', $e->getType()); + $this->assertEquals(422, $e->getStatus()); } public function provideThresholds(): array diff --git a/module/Core/test/Exception/InvalidUrlExceptionTest.php b/module/Core/test/Exception/InvalidUrlExceptionTest.php index 1b7de449..cb0a08bc 100644 --- a/module/Core/test/Exception/InvalidUrlExceptionTest.php +++ b/module/Core/test/Exception/InvalidUrlExceptionTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Throwable; +use function sprintf; + class InvalidUrlExceptionTest extends TestCase { /** @@ -18,10 +20,17 @@ class InvalidUrlExceptionTest extends TestCase */ public function properlyCreatesExceptionFromUrl(?Throwable $prev): void { - $e = InvalidUrlException::fromUrl('http://the_url.com', $prev); + $url = 'http://the_url.com'; + $expectedMessage = sprintf('Provided URL %s is invalid. Try with a different one.', $url); + $e = InvalidUrlException::fromUrl($url, $prev); - $this->assertEquals('Provided URL http://the_url.com is invalid. Try with a different one.', $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Invalid URL', $e->getTitle()); + $this->assertEquals('INVALID_URL', $e->getType()); + $this->assertEquals(['url' => $url], $e->getAdditionalData()); $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); + $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); $this->assertEquals($prev, $e->getPrevious()); } diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index d2008621..00efa3cf 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -15,8 +15,19 @@ class NonUniqueSlugExceptionTest extends TestCase */ public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, ?string $domain): void { + $expectedAdditional = ['customSlug' => $slug]; + if ($domain !== null) { + $expectedAdditional['domain'] = $domain; + } + $e = NonUniqueSlugException::fromSlug($slug, $domain); + $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Invalid custom slug', $e->getTitle()); + $this->assertEquals('INVALID_SLUG', $e->getType()); + $this->assertEquals(400, $e->getStatus()); + $this->assertEquals($expectedAdditional, $e->getAdditionalData()); } public function provideMessages(): iterable diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index 6f0d58f4..be02a66c 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -18,8 +18,19 @@ class ShortUrlNotFoundExceptionTest extends TestCase string $shortCode, ?string $domain ): void { + $expectedAdditional = ['shortCode' => $shortCode]; + if ($domain !== null) { + $expectedAdditional['domain'] = $domain; + } + $e = ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain); + $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Short URL not found', $e->getTitle()); + $this->assertEquals('INVALID_SHORTCODE', $e->getType()); + $this->assertEquals(404, $e->getStatus()); + $this->assertEquals($expectedAdditional, $e->getAdditionalData()); } public function provideMessages(): iterable diff --git a/module/Core/test/Exception/TagNotFoundExceptionTest.php b/module/Core/test/Exception/TagNotFoundExceptionTest.php new file mode 100644 index 00000000..ccee7b38 --- /dev/null +++ b/module/Core/test/Exception/TagNotFoundExceptionTest.php @@ -0,0 +1,28 @@ +assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Tag not found', $e->getTitle()); + $this->assertEquals('TAG_NOT_FOUND', $e->getType()); + $this->assertEquals(['tag' => $tag], $e->getAdditionalData()); + $this->assertEquals(404, $e->getStatus()); + } +} diff --git a/module/Rest/src/Exception/MissingAuthenticationException.php b/module/Rest/src/Exception/MissingAuthenticationException.php index 6ed76e2a..c00cb3e0 100644 --- a/module/Rest/src/Exception/MissingAuthenticationException.php +++ b/module/Rest/src/Exception/MissingAuthenticationException.php @@ -21,7 +21,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem public static function fromExpectedTypes(array $expectedTypes): self { $e = new self(sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', + 'Expected one of the following authentication headers, ["%s"], but none were provided', implode('", "', $expectedTypes) )); diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index 3526c5f5..d92f4a44 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -17,7 +17,7 @@ class AuthenticationTest extends ApiTestCase public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void { $expectedDetail = sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', + 'Expected one of the following authentication headers, ["%s"], but none were provided', implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) ); diff --git a/module/Rest/test/Authentication/RequestToAuthPluginTest.php b/module/Rest/test/Authentication/RequestToAuthPluginTest.php index 8d11b4d8..a49a4e19 100644 --- a/module/Rest/test/Authentication/RequestToAuthPluginTest.php +++ b/module/Rest/test/Authentication/RequestToAuthPluginTest.php @@ -37,7 +37,7 @@ class RequestToAuthPluginTest extends TestCase $this->expectException(MissingAuthenticationException::class); $this->expectExceptionMessage(sprintf( - 'Expected one of the following authentication headers, but none were provided, ["%s"]', + 'Expected one of the following authentication headers, ["%s"], but none were provided', implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) )); diff --git a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php new file mode 100644 index 00000000..84c72e75 --- /dev/null +++ b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php @@ -0,0 +1,43 @@ +assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Invalid authorization', $e->getTitle()); + $this->assertEquals('INVALID_AUTHORIZATION', $e->getType()); + $this->assertEquals(401, $e->getStatus()); + $this->assertEquals(['expectedTypes' => $expectedTypes], $e->getAdditionalData()); + } + + public function provideExpectedTypes(): iterable + { + yield [['foo', 'bar']]; + yield [['something']]; + yield [[]]; + yield [['foo', 'bar', 'baz']]; + } +} From 46c06202366f89603d37fed030083c81f3cbbf00 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 10:47:56 +0100 Subject: [PATCH 050/100] More test improvements trying to increase mutation score --- .../Middleware/CrossDomainMiddlewareTest.php | 28 ++++-- .../Rest/test/Service/ApiKeyServiceTest.php | 89 +++++++++---------- 2 files changed, 62 insertions(+), 55 deletions(-) diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index ebc88080..1716c19e 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -8,12 +8,14 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Rest\Authentication; use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware; use Zend\Diactoros\Response; use Zend\Diactoros\ServerRequest; use Zend\Expressive\Router\Route; use Zend\Expressive\Router\RouteResult; +use function implode; use function Zend\Stratigility\middleware; class CrossDomainMiddlewareTest extends TestCase @@ -39,6 +41,7 @@ class CrossDomainMiddlewareTest extends TestCase $this->assertSame($originalResponse, $response); $headers = $response->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); $this->assertArrayNotHasKey('Access-Control-Expose-Headers', $headers); $this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); @@ -59,8 +62,12 @@ class CrossDomainMiddlewareTest extends TestCase $this->assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); - $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); - $this->assertArrayHasKey('Access-Control-Expose-Headers', $headers); + + $this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertEquals(implode(', ', [ + Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME, + Authentication\Plugin\AuthorizationHeaderPlugin::HEADER_NAME, + ]), $response->getHeaderLine('Access-Control-Expose-Headers')); $this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); $this->assertArrayNotHasKey('Access-Control-Max-Age', $headers); $this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); @@ -70,18 +77,25 @@ class CrossDomainMiddlewareTest extends TestCase public function optionsRequestIncludesMoreHeaders(): void { $originalResponse = new Response(); - $request = (new ServerRequest())->withMethod('OPTIONS')->withHeader('Origin', 'local'); + $request = (new ServerRequest()) + ->withMethod('OPTIONS') + ->withHeader('Origin', 'local') + ->withHeader('Access-Control-Request-Headers', 'foo, bar, baz'); $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); $response = $this->middleware->process($request, $this->handler->reveal()); $this->assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); - $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); - $this->assertArrayHasKey('Access-Control-Expose-Headers', $headers); + + $this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); + $this->assertEquals(implode(', ', [ + Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME, + Authentication\Plugin\AuthorizationHeaderPlugin::HEADER_NAME, + ]), $response->getHeaderLine('Access-Control-Expose-Headers')); $this->assertArrayHasKey('Access-Control-Allow-Methods', $headers); - $this->assertArrayHasKey('Access-Control-Max-Age', $headers); - $this->assertArrayHasKey('Access-Control-Allow-Headers', $headers); + $this->assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age')); + $this->assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers')); } /** diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index d79cea41..caa50bf1 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -27,65 +27,49 @@ class ApiKeyServiceTest extends TestCase $this->service = new ApiKeyService($this->em->reveal()); } - /** @test */ - public function keyIsProperlyCreated() + /** + * @test + * @dataProvider provideCreationDate + */ + public function apiKeyIsProperlyCreated(?Chronos $date): void { $this->em->flush()->shouldBeCalledOnce(); $this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce(); - $key = $this->service->create(); - $this->assertNull($key->getExpirationDate()); - } - - /** @test */ - public function keyIsProperlyCreatedWithExpirationDate() - { - $this->em->flush()->shouldBeCalledOnce(); - $this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce(); - - $date = Chronos::parse('2030-01-01'); $key = $this->service->create($date); - $this->assertSame($date, $key->getExpirationDate()); + + $this->assertEquals($date, $key->getExpirationDate()); } - /** @test */ - public function checkReturnsFalseWhenKeyIsInvalid() + public function provideCreationDate(): iterable + { + yield 'no expiration date' => [null]; + yield 'expiration date' => [Chronos::parse('2030-01-01')]; + } + + /** + * @test + * @dataProvider provideInvalidApiKeys + */ + public function checkReturnsFalseForInvalidApiKeys(?ApiKey $invalidKey): void { $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn(null) + $repo->findOneBy(['key' => '12345'])->willReturn($invalidKey) ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); $this->assertFalse($this->service->check('12345')); } - /** @test */ - public function checkReturnsFalseWhenKeyIsDisabled() + public function provideInvalidApiKeys(): iterable { - $key = new ApiKey(); - $key->disable(); - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn($key) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - - $this->assertFalse($this->service->check('12345')); + yield 'non-existent api key' => [null]; + yield 'disabled api key' => [(new ApiKey())->disable()]; + yield 'expired api key' => [new ApiKey(Chronos::now()->subDay())]; } /** @test */ - public function checkReturnsFalseWhenKeyIsExpired() - { - $key = new ApiKey(Chronos::now()->subDay()); - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn($key) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - - $this->assertFalse($this->service->check('12345')); - } - - /** @test */ - public function checkReturnsTrueWhenConditionsAreFavorable() + public function checkReturnsTrueWhenConditionsAreFavorable(): void { $repo = $this->prophesize(EntityRepository::class); $repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey()) @@ -96,7 +80,7 @@ class ApiKeyServiceTest extends TestCase } /** @test */ - public function disableThrowsExceptionWhenNoTokenIsFound() + public function disableThrowsExceptionWhenNoApiKeyIsFound(): void { $repo = $this->prophesize(EntityRepository::class); $repo->findOneBy(['key' => '12345'])->willReturn(null) @@ -104,11 +88,12 @@ class ApiKeyServiceTest extends TestCase $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); $this->expectException(InvalidArgumentException::class); + $this->service->disable('12345'); } /** @test */ - public function disableReturnsDisabledKeyWhenFOund() + public function disableReturnsDisabledApiKeyWhenFound(): void { $key = new ApiKey(); $repo = $this->prophesize(EntityRepository::class); @@ -125,24 +110,32 @@ class ApiKeyServiceTest extends TestCase } /** @test */ - public function listFindsAllApiKeys() + public function listFindsAllApiKeys(): void { + $expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()]; + $repo = $this->prophesize(EntityRepository::class); - $repo->findBy([])->willReturn([]) + $repo->findBy([])->willReturn($expectedApiKeys) ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - $this->service->listKeys(); + $result = $this->service->listKeys(); + + $this->assertEquals($expectedApiKeys, $result); } /** @test */ - public function listEnabledFindsOnlyEnabledApiKeys() + public function listEnabledFindsOnlyEnabledApiKeys(): void { + $expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()]; + $repo = $this->prophesize(EntityRepository::class); - $repo->findBy(['enabled' => true])->willReturn([]) + $repo->findBy(['enabled' => true])->willReturn($expectedApiKeys) ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - $this->service->listKeys(true); + $result = $this->service->listKeys(true); + + $this->assertEquals($expectedApiKeys, $result); } } From fc5904e7433094cf962c7ab8fe6f965c9f0a0175 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 10:58:48 +0100 Subject: [PATCH 051/100] Improved BodyParserMiddlewareTest to kill more mutants --- .../Middleware/BodyParserMiddlewareTest.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index fb69c695..829b4b59 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\Prophecy\ProphecyInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware; @@ -31,7 +32,10 @@ class BodyParserMiddlewareTest extends TestCase */ public function requestsFromOtherMethodsJustFallbackToNextMiddleware(string $method): void { - $request = (new ServerRequest())->withMethod($method); + $request = $this->prophesize(ServerRequestInterface::class); + $request->getMethod()->willReturn($method); + $request->getParsedBody()->willReturn([]); + $this->assertHandlingRequestJustFallsBackToNext($request); } @@ -45,18 +49,25 @@ class BodyParserMiddlewareTest extends TestCase /** @test */ public function requestsWithNonEmptyBodyJustFallbackToNextMiddleware(): void { - $request = (new ServerRequest())->withParsedBody(['foo' => 'bar'])->withMethod('POST'); + $request = $this->prophesize(ServerRequestInterface::class); + $request->getMethod()->willReturn('POST'); + $request->getParsedBody()->willReturn(['foo' => 'bar']); + $this->assertHandlingRequestJustFallsBackToNext($request); } - private function assertHandlingRequestJustFallsBackToNext(ServerRequestInterface $request): void + private function assertHandlingRequestJustFallsBackToNext(ProphecyInterface $requestMock): void { + $getContentType = $requestMock->getHeaderLine('Content-type')->willReturn(''); + $request = $requestMock->reveal(); + $nextHandler = $this->prophesize(RequestHandlerInterface::class); $handle = $nextHandler->handle($request)->willReturn(new Response()); $this->middleware->process($request, $nextHandler->reveal()); $handle->shouldHaveBeenCalledOnce(); + $getContentType->shouldNotHaveBeenCalled(); } /** @test */ From 57070ef155bb19dda0e1b7cf0adf79137687a368 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 12:04:31 +0100 Subject: [PATCH 052/100] Improved Rest's ConfigProvider test to kill more mutants --- module/Rest/src/ConfigProvider.php | 17 ++++++++++++----- module/Rest/test/ConfigProviderTest.php | 24 +++++++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index d942cf51..0c0e99a5 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -4,19 +4,26 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; -use Zend\Config\Factory; -use Zend\Stdlib\Glob; - +use function Shlinkio\Shlink\Common\loadConfigFromGlob; use function sprintf; class ConfigProvider { private const ROUTES_PREFIX = '/rest/v{version:1|2}'; + /** @var callable */ + private $loadConfig; + + public function __construct(?callable $loadConfig = null) + { + $this->loadConfig = $loadConfig ?? function (string $glob) { + return loadConfigFromGlob($glob); + }; + } + public function __invoke() { - /** @var array $config */ - $config = Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE)); + $config = ($this->loadConfig)(__DIR__ . '/../config/{,*.}config.php'); return $this->applyRoutesPrefix($config); } diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 3cd574b3..2922faad 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -20,9 +20,31 @@ class ConfigProviderTest extends TestCase /** @test */ public function properConfigIsReturned(): void { - $config = $this->configProvider->__invoke(); + $config = ($this->configProvider)(); $this->assertArrayHasKey('routes', $config); $this->assertArrayHasKey('dependencies', $config); } + + /** @test */ + public function routesAreProperlyPrefixed(): void + { + $configProvider = new ConfigProvider(function () { + return [ + 'routes' => [ + ['path' => '/foo'], + ['path' => '/bar'], + ['path' => '/baz/foo'], + ], + ]; + }); + + $config = $configProvider(); + + $this->assertEquals([ + ['path' => '/rest/v{version:1|2}/foo'], + ['path' => '/rest/v{version:1|2}/bar'], + ['path' => '/rest/v{version:1|2}/baz/foo'], + ], $config['routes']); + } } From 7f43890713b2f1c0852c0a2576d9824fc16b4623 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 12:26:31 +0100 Subject: [PATCH 053/100] Improved CreateShortUrlAction test so that it cover more mutants --- .../ShortUrl/CreateShortUrlActionTest.php | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 92a3c2aa..37f737f9 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -4,14 +4,17 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; +use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; 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\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\ServerRequestFactory; use Zend\Diactoros\Uri; use function strpos; @@ -41,20 +44,41 @@ class CreateShortUrlActionTest extends TestCase $this->action->handle(new ServerRequest()); } - /** @test */ - public function properShortcodeConversionReturnsData(): void + /** + * @test + * @dataProvider provideRequestBodies + */ + public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta): void { $shortUrl = new ShortUrl(''); - $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) - ->willReturn($shortUrl) - ->shouldBeCalledOnce(); + $shorten = $this->urlShortener->urlToShortCode( + Argument::type(Uri::class), + Argument::type('array'), + $expectedMeta + )->willReturn($shortUrl); - $request = (new ServerRequest())->withParsedBody([ - 'longUrl' => 'http://www.domain.com/foo/bar', - ]); + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); $response = $this->action->handle($request); + $this->assertEquals(200, $response->getStatusCode()); $this->assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0); + $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 [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()]; + yield [$fullMeta, ShortUrlMeta::createFromRawData($fullMeta)]; } /** From 058cdf7a82272d7447582ffae8fbbb535a14b409 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Dec 2019 12:34:26 +0100 Subject: [PATCH 054/100] Enforced a min msi of 80% --- CHANGELOG.md | 1 + composer.json | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6394408b..2a7617a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this #### Changed * [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. +* [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%. #### Deprecated diff --git a/composer.json b/composer.json index fd3840bb..0ded66fe 100644 --- a/composer.json +++ b/composer.json @@ -130,9 +130,9 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", "test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage", - "infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered", - "infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build", - "infect:show": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --show-mutations", + "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered", + "infect:ci": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --coverage=build", + "infect:show": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --show-mutations", "infect:test": [ "@test:unit:ci", "@infect:ci" From e5f262869ca252d22cc2ad49f779c55bfe3901e4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 6 Dec 2019 23:40:27 +0100 Subject: [PATCH 055/100] Improved tag conflict docs and tests --- docs/swagger/paths/v1_tags.json | 10 +++++++ .../Exception/TagConflictExceptionTest.php | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 module/Core/test/Exception/TagConflictExceptionTest.php diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index da0159bd..a0fcc512 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -229,6 +229,16 @@ } } }, + "409": { + "description": "The name provided in newName param is already in use for another tag.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "500": { "description": "Unexpected error.", "content": { diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php new file mode 100644 index 00000000..f09e3a32 --- /dev/null +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -0,0 +1,29 @@ +assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($expectedMessage, $e->getDetail()); + $this->assertEquals('Tag conflict', $e->getTitle()); + $this->assertEquals('TAG_CONFLICT', $e->getType()); + $this->assertEquals(['oldName' => $oldName, 'newName' => $newName], $e->getAdditionalData()); + $this->assertEquals(409, $e->getStatus()); + } +} From a0a1d3de728b109317068d6e1d3288dbdb424f47 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Dec 2019 09:31:46 +0100 Subject: [PATCH 056/100] Used stable docker tag on every docker run example --- docker/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/README.md b/docker/README.md index 32451338..66e8e743 100644 --- a/docker/README.md +++ b/docker/README.md @@ -19,7 +19,7 @@ It also expects these two env vars to be provided, in order to properly generate So based on this, to run shlink on a local docker service, you should run a command like this: ```bash -docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink +docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink:stable ``` ### Interact with shlink's CLI on a running container. @@ -73,13 +73,13 @@ It is possible to use a set of env vars to make this shlink instance interact wi Taking this into account, you could run shlink on a local docker service like this: ```bash -docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink +docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink:stable ``` You could even link to a local database running on a different container: ```bash -docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink +docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink:stable ``` > If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first. @@ -145,7 +145,7 @@ docker run \ -e "BASE_PATH=/my-campaign" \ -e WEB_WORKER_NUM=64 \ -e TASK_WORKER_NUM=32 \ - shlinkio/shlink + shlinkio/shlink:stable ``` ## Provide config via volumes @@ -192,7 +192,7 @@ The whole configuration should have this format, but it can be split into multip Once created just run shlink with the volume: ```bash -docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink +docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable ``` ## Multi instance considerations From 843e9432511ae6446da032211b612466600cfe9c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 7 Dec 2019 21:01:14 +0100 Subject: [PATCH 057/100] Updated to guzzle 6.5 and removed custom code --- composer.json | 2 +- module/Core/src/Util/UrlValidator.php | 18 +++--------------- module/Core/test/Util/UrlValidatorTest.php | 19 ++++++------------- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index 0ded66fe..84290c2a 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "endroid/qr-code": "^3.6", "firebase/php-jwt": "^4.0", "geoip2/geoip2": "^2.9", - "guzzlehttp/guzzle": "^6.3", + "guzzlehttp/guzzle": "^6.5", "lstrojny/functional-php": "^1.9", "mikehaertl/phpwkhtmltopdf": "^2.2", "monolog/monolog": "^2.0", diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index a3ffe0d8..c91f37ff 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -10,13 +10,8 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; -use Zend\Diactoros\Uri; use function Functional\contains; -use function idn_to_ascii; - -use const IDNA_DEFAULT; -use const INTL_IDNA_VARIANT_UTS46; class UrlValidator implements UrlValidatorInterface, RequestMethodInterface, StatusCodeInterface { @@ -43,17 +38,10 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface, Sta */ private function doValidateUrl(string $url, int $redirectNum = 1): void { - // FIXME Guzzle is about to add support for this https://github.com/guzzle/guzzle/pull/2286 - // Remove custom implementation and manual redirect handling when Guzzle's PR is merged - $uri = new Uri($url); - $originalHost = $uri->getHost(); - $normalizedHost = idn_to_ascii($originalHost, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); - if ($originalHost !== $normalizedHost) { - $uri = $uri->withHost($normalizedHost); - } - + // TODO Guzzle does not properly handle IDNs on redirects, just on first request. + // Because of that, we have to handle redirects manually. try { - $resp = $this->httpClient->request(self::METHOD_GET, (string) $uri, [ + $resp = $this->httpClient->request(self::METHOD_GET, $url, [ // RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], RequestOptions::ALLOW_REDIRECTS => false, ]); diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 5dbfe582..af558e44 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -64,31 +64,24 @@ class UrlValidatorTest extends TestCase }); } - /** - * @test - * @dataProvider provideUrls - */ - public function expectedUrlIsCalledInOrderToVerifyProvidedUrl(string $providedUrl, string $expectedUrl): void + /** @test */ + public function expectedUrlIsCalledWhenTryingToVerify(): void { + $expectedUrl = 'http://foobar.com'; + $request = $this->httpClient->request( RequestMethodInterface::METHOD_GET, $expectedUrl, Argument::cetera() )->willReturn(new Response()); - $this->urlValidator->validateUrl($providedUrl); + $this->urlValidator->validateUrl($expectedUrl); $request->shouldHaveBeenCalledOnce(); } - public function provideUrls(): iterable - { - yield 'regular domain' => ['http://foobar.com', 'http://foobar.com']; - yield 'IDN' => ['https://tést.shlink.io', 'https://xn--tst-bma.shlink.io']; - } - /** @test */ - public function considersUrlValidWhenTooManyRedirectsAreReturned(): void + public function urlIsConsideredValidWhenTooManyRedirectsAreReturned(): void { $request = $this->httpClient->request(Argument::cetera())->willReturn( new Response('php://memory', 302, ['Location' => 'http://foo.com']) From 83b9160ab11898870b83774c008fde1a0bcb188a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 9 Dec 2019 18:16:57 +0100 Subject: [PATCH 058/100] Updated docker image build script so that it sets shlink version to 'latest' when source branch is develop --- hooks/build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/build b/hooks/build index d7d39368..6b381d74 100755 --- a/hooks/build +++ b/hooks/build @@ -1,7 +1,7 @@ #!/bin/bash set -ex -if [[ ${SOURCE_BRANCH} == 'master' ]]; then +if [[ ${SOURCE_BRANCH} == 'develop' ]]; then SHLINK_RELEASE='latest' else SHLINK_RELEASE=${SOURCE_BRANCH#?} From a03179743de4354647655498446171d74fc1cb29 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 9 Dec 2019 18:18:03 +0100 Subject: [PATCH 059/100] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ea782b..c6fc5f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this #### Fixed -* *Nothing* +* [#570](https://github.com/shlinkio/shlink/issues/570) Fixed shlink version generated for docker images when building from `develop` branch. ## 1.20.2 - 2019-12-06 From c484e32641768ae24ccfe90133a5c02f20ce23e3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Dec 2019 10:57:29 +0100 Subject: [PATCH 060/100] Replaced PHP 7.4 snapshot by regular 7.4 --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5385b1c3..a4b5d1de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,11 +7,11 @@ branches: php: - '7.2' - '7.3' - - '7.4snapshot' + - '7.4' matrix: allow_failures: - - php: '7.4snapshot' + - php: '7.4' services: - mysql From 1183d651843cd6ed0657b5c320f1c5cc8fdebe25 Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 11:58:08 -0300 Subject: [PATCH 061/100] Add date range filter to short url repository interface --- module/Core/src/Repository/ShortUrlRepositoryInterface.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index da5cef61..23c555b3 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Common\Persistence\ObjectRepository; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; interface ShortUrlRepositoryInterface extends ObjectRepository @@ -19,7 +20,8 @@ interface ShortUrlRepositoryInterface extends ObjectRepository ?int $offset = null, ?string $searchTerm = null, array $tags = [], - $orderBy = null + $orderBy = null, + ?DateRange $dateRange = null ): array; /** From 5928f28699a72005bfc67b2147188e0604458c7f Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 11:58:52 -0300 Subject: [PATCH 062/100] Add date range filter to short url repository --- .../Core/src/Repository/ShortUrlRepository.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 47f8f985..d8f71b27 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use function array_column; @@ -27,7 +28,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI ?int $offset = null, ?string $searchTerm = null, array $tags = [], - $orderBy = null + $orderBy = null, + ?DateRange $dateRange = null ): array { $qb = $this->createListQueryBuilder($searchTerm, $tags); $qb->select('DISTINCT s'); @@ -40,6 +42,18 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $qb->setFirstResult($offset); } + // Date filters + if ($dateRange !== null) { + if ($dateRange->getStartDate() !== null) { + $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); + $qb->setParameter('startDate', $dateRange->getStartDate()); + } + if ($dateRange->getEndDate() !== null) { + $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); + $qb->setParameter('endDate', $dateRange->getEndDate()); + } + } + // In case the ordering has been specified, the query could be more complex. Process it if ($orderBy !== null) { return $this->processOrderByForList($qb, $orderBy); From 661efcb51ffa6dfecd20c4aa2421043a0cc594d1 Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 12:01:56 -0300 Subject: [PATCH 063/100] Add date range filter to short url repository adapter --- .../Paginator/Adapter/ShortUrlRepositoryAdapter.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 80ffb1e8..90ae55f1 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Zend\Paginator\Adapter\AdapterInterface; @@ -22,17 +23,21 @@ class ShortUrlRepositoryAdapter implements AdapterInterface private $orderBy; /** @var array */ private $tags; + /** @var DateRange|null */ + private $dateRange; public function __construct( ShortUrlRepositoryInterface $repository, $searchTerm = null, array $tags = [], - $orderBy = null + $orderBy = null, + ?DateRange $dateRange = null ) { $this->repository = $repository; $this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null; $this->orderBy = $orderBy; $this->tags = $tags; + $this->dateRange = $dateRange; } /** @@ -49,7 +54,8 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $offset, $this->searchTerm, $this->tags, - $this->orderBy + $this->orderBy, + $this->dateRange, ); } From f9ba322547cb2a0177b8a402d181e07e2873a349 Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 13:55:03 -0300 Subject: [PATCH 064/100] Add date range filter to list urls endpoint parameters --- .../Rest/src/Action/ShortUrl/ListShortUrlsAction.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 157fbe06..b448b507 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; +use Cake\Chronos\Chronos; use InvalidArgumentException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -56,11 +58,19 @@ class ListShortUrlsAction extends AbstractRestAction */ private function queryToListParams(array $query): array { + $dateRange = null; + $dateStart = isset($query['dateStart']) ? Chronos::parse($query['dateStart']) : null; + $dateEnd = isset($query['dateEnd']) ? Chronos::parse($query['dateEnd']) : null; + if ($dateStart != null || $dateEnd != null) { + $dateRange = new DateRange($dateStart, $dateEnd); + } + return [ (int) ($query['page'] ?? 1), $query['searchTerm'] ?? null, $query['tags'] ?? [], $query['orderBy'] ?? null, + $dateRange, ]; } } From 27008505e53d410c770982ac3212a3a69b4e3e1e Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 15:03:39 -0300 Subject: [PATCH 065/100] Add date range filter to short url service interface --- module/Core/src/Service/ShortUrlServiceInterface.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index cb2c7dca..7c105c59 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -12,11 +13,16 @@ use Zend\Paginator\Paginator; interface ShortUrlServiceInterface { /** + * @param int $page + * @param string|null $searchQuery * @param string[] $tags * @param array|string|null $orderBy * @return ShortUrl[]|Paginator + * @param DateRange|null $dateRange + * + * @return ShortUrl[]|Paginator */ - public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null); + public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null, ?DateRange $dateRange = null); /** * @param string[] $tags From f17c46bbed495e349ec5c702cd96013b8492a0a0 Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 15:10:09 -0300 Subject: [PATCH 066/100] Add date range filter to short url service --- module/Core/src/Service/ShortUrlService.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 7719cafe..e82b3741 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -28,15 +29,19 @@ class ShortUrlService implements ShortUrlServiceInterface } /** + * @param int $page + * @param string|null $searchQuery * @param string[] $tags * @param array|string|null $orderBy + * @param DateRange|null $dateRange + * * @return ShortUrl[]|Paginator */ - public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null) + public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null, ?DateRange $dateRange = null) { /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); - $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $searchQuery, $tags, $orderBy)); + $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $searchQuery, $tags, $orderBy, $dateRange)); $paginator->setItemCountPerPage(ShortUrlRepositoryAdapter::ITEMS_PER_PAGE) ->setCurrentPageNumber($page); From a28e7987e6af276ea1721a8231191fca3f11138e Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 18:32:58 -0300 Subject: [PATCH 067/100] fixup! Add date range filter to list urls endpoint parameters --- module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index b448b507..5cb8aa90 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -59,10 +59,10 @@ class ListShortUrlsAction extends AbstractRestAction private function queryToListParams(array $query): array { $dateRange = null; - $dateStart = isset($query['dateStart']) ? Chronos::parse($query['dateStart']) : null; - $dateEnd = isset($query['dateEnd']) ? Chronos::parse($query['dateEnd']) : null; - if ($dateStart != null || $dateEnd != null) { - $dateRange = new DateRange($dateStart, $dateEnd); + $startDate = isset($query['startDate']) ? Chronos::parse($query['startDate']) : null; + $endDate = isset($query['endDate']) ? Chronos::parse($query['endDate']) : null; + if ($startDate != null || $endDate != null) { + $dateRange = new DateRange($startDate, $endDate); } return [ From d7ffcd903da1fc31c2bfec542a500d3d9fed5540 Mon Sep 17 00:00:00 2001 From: Alejandro Medina Date: Sat, 14 Dec 2019 18:42:02 -0300 Subject: [PATCH 068/100] Add date filter fields to short urls documentation --- docs/swagger/paths/v1_short-urls.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index d0aebfec..46708e22 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -54,6 +54,24 @@ "visits" ] } + }, + { + "name": "startDate", + "in": "query", + "description": "The date (in ISO-8601 format) from which we want to get short URLs.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endDate", + "in": "query", + "description": "The date (in ISO-8601 format) until which we want to get short URLs.", + "required": false, + "schema": { + "type": "string" + } } ], "security": [ From 99fd5f937ed3d4b75abf33662b8a0288d83060c8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 15:16:18 +0100 Subject: [PATCH 069/100] Fixed existing tests and coding styles --- module/Core/src/Service/ShortUrlService.php | 12 +++++++----- module/Core/src/Service/ShortUrlServiceInterface.php | 12 +++++++----- .../Adapter/ShortUrlRepositoryAdapterTest.php | 2 +- .../Rest/src/Action/ShortUrl/ListShortUrlsAction.php | 2 +- .../test/Action/ShortUrl/ListShortUrlsActionTest.php | 3 ++- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index e82b3741..15a3b432 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -29,16 +29,18 @@ class ShortUrlService implements ShortUrlServiceInterface } /** - * @param int $page - * @param string|null $searchQuery * @param string[] $tags * @param array|string|null $orderBy - * @param DateRange|null $dateRange * * @return ShortUrl[]|Paginator */ - public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null, ?DateRange $dateRange = null) - { + public function listShortUrls( + int $page = 1, + ?string $searchQuery = null, + array $tags = [], + $orderBy = null, + ?DateRange $dateRange = null + ) { /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $searchQuery, $tags, $orderBy, $dateRange)); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 7c105c59..6e3fe199 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -13,16 +13,18 @@ use Zend\Paginator\Paginator; interface ShortUrlServiceInterface { /** - * @param int $page - * @param string|null $searchQuery * @param string[] $tags * @param array|string|null $orderBy - * @return ShortUrl[]|Paginator - * @param DateRange|null $dateRange * * @return ShortUrl[]|Paginator */ - public function listShortUrls(int $page = 1, ?string $searchQuery = null, array $tags = [], $orderBy = null, ?DateRange $dateRange = null); + public function listShortUrls( + int $page = 1, + ?string $searchQuery = null, + array $tags = [], + $orderBy = null, + ?DateRange $dateRange = null + ); /** * @param string[] $tags diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index a7229147..391fdad6 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -25,7 +25,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase /** @test */ public function getItemsFallbacksToFindList(): void { - $this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order')->shouldBeCalledOnce(); + $this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order', null)->shouldBeCalledOnce(); $this->adapter->getItems(5, 10); } diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 5cb8aa90..24c44628 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -61,7 +61,7 @@ class ListShortUrlsAction extends AbstractRestAction $dateRange = null; $startDate = isset($query['startDate']) ? Chronos::parse($query['startDate']) : null; $endDate = isset($query['endDate']) ? Chronos::parse($query['endDate']) : null; - if ($startDate != null || $endDate != null) { + if ($startDate !== null || $endDate !== null) { $dateRange = new DateRange($startDate, $endDate); } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 4197aba8..cfe9a683 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -49,7 +49,8 @@ class ListShortUrlsActionTest extends TestCase $expectedPage, $expectedSearchTerm, $expectedTags, - $expectedOrderBy + $expectedOrderBy, + null )->willReturn(new Paginator(new ArrayAdapter())); /** @var JsonResponse $response */ From 03a92e555601bcf2bc40e2ce6a13b99c554c13dd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 15:22:03 +0100 Subject: [PATCH 070/100] Fixed trailing method comma which is not compatible with PHP 7.2 --- module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 90ae55f1..f275c757 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -55,7 +55,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->searchTerm, $this->tags, $this->orderBy, - $this->dateRange, + $this->dateRange ); } From 839ca318218f2446ec86c66944adfc8a2789f6fe Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 21:46:27 +0100 Subject: [PATCH 071/100] Ensured date range filtering is also passed to the count function on ShortUrlRepository --- .../Adapter/ShortUrlRepositoryAdapter.php | 2 +- .../src/Repository/ShortUrlRepository.php | 34 ++++++------ .../ShortUrlRepositoryInterface.php | 9 +--- .../src/Repository/TagRepositoryInterface.php | 2 +- .../Repository/VisitRepositoryInterface.php | 2 +- .../PersistenceDomainResolverTest.php | 2 +- .../Adapter/ShortUrlRepositoryAdapterTest.php | 52 ++++++++++++++----- .../Rest/test-api/Fixtures/ApiKeyFixture.php | 2 +- .../test-api/Fixtures/ShortUrlsFixture.php | 2 +- module/Rest/test-api/Fixtures/TagsFixture.php | 2 +- .../Rest/test-api/Fixtures/VisitsFixture.php | 2 +- 11 files changed, 68 insertions(+), 43 deletions(-) diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index f275c757..de382d17 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -70,6 +70,6 @@ class ShortUrlRepositoryAdapter implements AdapterInterface */ public function count(): int { - return $this->repository->countList($this->searchTerm, $this->tags); + return $this->repository->countList($this->searchTerm, $this->tags, $this->dateRange); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index d8f71b27..40635f83 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -31,7 +31,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $orderBy = null, ?DateRange $dateRange = null ): array { - $qb = $this->createListQueryBuilder($searchTerm, $tags); + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); $qb->select('DISTINCT s'); // Set limit and offset @@ -42,18 +42,6 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $qb->setFirstResult($offset); } - // Date filters - if ($dateRange !== null) { - if ($dateRange->getStartDate() !== null) { - $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->getStartDate()); - } - if ($dateRange->getEndDate() !== null) { - $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->getEndDate()); - } - } - // In case the ordering has been specified, the query could be more complex. Process it if ($orderBy !== null) { return $this->processOrderByForList($qb, $orderBy); @@ -91,7 +79,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return $qb->getQuery()->getResult(); } - public function countList(?string $searchTerm = null, array $tags = []): int + public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int { $qb = $this->createListQueryBuilder($searchTerm, $tags); $qb->select('COUNT(DISTINCT s)'); @@ -99,12 +87,26 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return (int) $qb->getQuery()->getSingleScalarResult(); } - private function createListQueryBuilder(?string $searchTerm = null, array $tags = []): QueryBuilder - { + private function createListQueryBuilder( + ?string $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null + ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's'); $qb->where('1=1'); + if ($dateRange !== null) { + if ($dateRange->getStartDate() !== null) { + $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); + $qb->setParameter('startDate', $dateRange->getStartDate()); + } + if ($dateRange->getEndDate() !== null) { + $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); + $qb->setParameter('endDate', $dateRange->getEndDate()); + } + } + // Apply search term to every searchable field if not empty if (! empty($searchTerm)) { // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 23c555b3..8695021a 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -4,15 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\Persistence\ObjectRepository; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; interface ShortUrlRepositoryInterface extends ObjectRepository { /** - * Gets a list of elements using provided filtering data - * * @param string|array|null $orderBy */ public function findList( @@ -24,10 +22,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository ?DateRange $dateRange = null ): array; - /** - * Counts the number of elements in a list using provided filtering data - */ - public function countList(?string $searchTerm = null, array $tags = []): int; + public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int; public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl; diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 182df847..e253f7a4 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\Persistence\ObjectRepository; interface TagRepositoryInterface extends ObjectRepository { diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index a0bbfe99..e70c989e 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\Persistence\ObjectRepository; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; diff --git a/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php b/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php index be0640b6..4ba796ab 100644 --- a/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php +++ b/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Domain\Resolver; -use Doctrine\Common\Persistence\ObjectRepository; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ObjectRepository; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 391fdad6..8bf69faf 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -4,35 +4,63 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Paginator\Adapter; +use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; class ShortUrlRepositoryAdapterTest extends TestCase { - /** @var ShortUrlRepositoryAdapter */ - private $adapter; /** @var ObjectProphecy */ private $repo; public function setUp(): void { $this->repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $this->adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), 'search', ['foo', 'bar'], 'order'); } - /** @test */ - public function getItemsFallbacksToFindList(): void - { - $this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order', null)->shouldBeCalledOnce(); - $this->adapter->getItems(5, 10); + /** + * @test + * @dataProvider provideFilteringArgs + */ + public function getItemsFallsBackToFindList( + $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null, + $orderBy = null + ): void { + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, $orderBy, $dateRange); + + $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce(); + $adapter->getItems(5, 10); } - /** @test */ - public function countFallbacksToCountList(): void + /** + * @test + * @dataProvider provideFilteringArgs + */ + public function countFallsBackToCountList($searchTerm = null, array $tags = [], ?DateRange $dateRange = null): void { - $this->repo->countList('search', ['foo', 'bar'])->shouldBeCalledOnce(); - $this->adapter->count(); + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, null, $dateRange); + + $this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce(); + $adapter->count(); + } + + public function provideFilteringArgs(): iterable + { + yield []; + yield ['search']; + yield ['search', []]; + yield ['search', ['foo', 'bar']]; + yield ['search', ['foo', 'bar'], null, 'order']; + yield ['search', ['foo', 'bar'], new DateRange(), 'order']; + yield ['search', ['foo', 'bar'], new DateRange(Chronos::now()), 'order']; + yield ['search', ['foo', 'bar'], new DateRange(null, Chronos::now()), 'order']; + yield ['search', ['foo', 'bar'], new DateRange(Chronos::now(), Chronos::now()), 'order']; + yield ['search', ['foo', 'bar'], new DateRange(Chronos::now())]; + yield [null, ['foo', 'bar'], new DateRange(Chronos::now(), Chronos::now())]; } } diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 2bc26187..971054fd 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -6,7 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\FixtureInterface; -use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectManager; use ReflectionObject; use Shlinkio\Shlink\Rest\Entity\ApiKey; diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 253b0032..3282e575 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -6,7 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; -use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectManager; use ReflectionObject; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index f498796b..5bd10ca7 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -7,7 +7,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; -use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectManager; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 9c1594a4..2c85c1a1 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -6,7 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; -use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectManager; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Visitor; From 8ad8b08aa402ed0f75b06b16f4987d726010df56 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 21:55:48 +0100 Subject: [PATCH 072/100] Improved ListShortUrlsActionTest covering different scenarios in which date ranges are provided --- .../Action/ShortUrl/ListShortUrlsAction.php | 17 ++++--- .../ShortUrl/ListShortUrlsActionTest.php | 50 +++++++++++++++---- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 24c44628..87e7930b 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -58,19 +58,20 @@ class ListShortUrlsAction extends AbstractRestAction */ private function queryToListParams(array $query): array { - $dateRange = null; - $startDate = isset($query['startDate']) ? Chronos::parse($query['startDate']) : null; - $endDate = isset($query['endDate']) ? Chronos::parse($query['endDate']) : null; - if ($startDate !== null || $endDate !== null) { - $dateRange = new DateRange($startDate, $endDate); - } - return [ (int) ($query['page'] ?? 1), $query['searchTerm'] ?? null, $query['tags'] ?? [], $query['orderBy'] ?? null, - $dateRange, + $this->determineDateRangeFromQuery($query), ]; } + + private function determineDateRangeFromQuery(array $query): DateRange + { + return new DateRange( + isset($query['startDate']) ? Chronos::parse($query['startDate']) : null, + isset($query['endDate']) ? Chronos::parse($query['endDate']) : null + ); + } } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index cfe9a683..1dfdc258 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; +use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; use Zend\Diactoros\Response\JsonResponse; @@ -43,14 +45,15 @@ class ListShortUrlsActionTest extends TestCase int $expectedPage, ?string $expectedSearchTerm, array $expectedTags, - ?string $expectedOrderBy + ?string $expectedOrderBy, + DateRange $expectedDateRange ): void { $listShortUrls = $this->service->listShortUrls( $expectedPage, $expectedSearchTerm, $expectedTags, $expectedOrderBy, - null + $expectedDateRange )->willReturn(new Paginator(new ArrayAdapter())); /** @var JsonResponse $response */ @@ -66,17 +69,44 @@ class ListShortUrlsActionTest extends TestCase public function provideFilteringData(): iterable { - yield [[], 1, null, [], null]; - yield [['page' => 10], 10, null, [], null]; - yield [['page' => null], 1, null, [], null]; - yield [['page' => '8'], 8, null, [], null]; - yield [['searchTerm' => $searchTerm = 'foo'], 1, $searchTerm, [], null]; - yield [['tags' => $tags = ['foo','bar']], 1, null, $tags, null]; - yield [['orderBy' => $orderBy = 'something'], 1, null, [], $orderBy]; + yield [[], 1, null, [], null, new DateRange()]; + yield [['page' => 10], 10, null, [], null, new DateRange()]; + yield [['page' => null], 1, null, [], null, new DateRange()]; + yield [['page' => '8'], 8, null, [], null, new DateRange()]; + yield [['searchTerm' => $searchTerm = 'foo'], 1, $searchTerm, [], null, new DateRange()]; + yield [['tags' => $tags = ['foo','bar']], 1, null, $tags, null, new DateRange()]; + yield [['orderBy' => $orderBy = 'something'], 1, null, [], $orderBy, new DateRange()]; yield [[ 'page' => '2', 'orderBy' => $orderBy = 'something', 'tags' => $tags = ['one', 'two'], - ], 2, null, $tags, $orderBy]; + ], 2, null, $tags, $orderBy, new DateRange()]; + yield [ + ['startDate' => $date = Chronos::now()->toAtomString()], + 1, + null, + [], + null, + new DateRange(Chronos::parse($date)), + ]; + yield [ + ['endDate' => $date = Chronos::now()->toAtomString()], + 1, + null, + [], + null, + new DateRange(null, Chronos::parse($date)), + ]; + yield [ + [ + 'startDate' => $startDate = Chronos::now()->subDays(10)->toAtomString(), + 'endDate' => $endDate = Chronos::now()->toAtomString(), + ], + 1, + null, + [], + null, + new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)), + ]; } } From 35eeaf4282697851ccd8bc4bc3ccc1748ab4f0eb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 22:13:11 +0100 Subject: [PATCH 073/100] Improved repository tests covering fetching and counting filtered short URL lists --- .../src/Repository/ShortUrlRepository.php | 28 ++++++++----------- .../Repository/ShortUrlRepositoryTest.php | 23 +++++++++++++-- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 40635f83..1c26c122 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -81,7 +81,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int { - $qb = $this->createListQueryBuilder($searchTerm, $tags); + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); $qb->select('COUNT(DISTINCT s)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -96,15 +96,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $qb->from(ShortUrl::class, 's'); $qb->where('1=1'); - if ($dateRange !== null) { - if ($dateRange->getStartDate() !== null) { - $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->getStartDate()); - } - if ($dateRange->getEndDate() !== null) { - $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->getEndDate()); - } + if ($dateRange !== null && $dateRange->getStartDate() !== null) { + $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); + $qb->setParameter('startDate', $dateRange->getStartDate()); + } + if ($dateRange !== null && $dateRange->getEndDate() !== null) { + $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); + $qb->setParameter('endDate', $dateRange->getEndDate()); } // Apply search term to every searchable field if not empty @@ -114,14 +112,12 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $qb->leftJoin('s.tags', 't'); } - $conditions = [ + // Apply search conditions + $qb->andWhere($qb->expr()->orX( $qb->expr()->like('s.longUrl', ':searchPattern'), $qb->expr()->like('s.shortCode', ':searchPattern'), - $qb->expr()->like('t.name', ':searchPattern'), - ]; - - // Unpack and apply search conditions - $qb->andWhere($qb->expr()->orX(...$conditions)); + $qb->expr()->like('t.name', ':searchPattern') + )); $qb->setParameter('searchPattern', '%' . $searchTerm . '%'); } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index f3913ea2..2006623a 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -6,6 +6,8 @@ namespace ShlinkioTest\Shlink\Core\Repository; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; +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; @@ -108,7 +110,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase } /** @test */ - public function findListProperlyFiltersByTagAndSearchTerm(): void + public function findListProperlyFiltersResult(): void { $tag = new Tag('bar'); $this->getEntityManager()->persist($tag); @@ -124,12 +126,17 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($bar); $foo2 = new ShortUrl('foo_2'); + $ref = new ReflectionObject($foo2); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($foo2, Chronos::now()->subDays(5)); $this->getEntityManager()->persist($foo2); $this->getEntityManager()->flush(); $result = $this->repo->findList(null, null, 'foo', ['bar']); $this->assertCount(1, $result); + $this->assertEquals(1, $this->repo->countList('foo', ['bar'])); $this->assertSame($foo, $result[0]); $result = $this->repo->findList(); @@ -141,12 +148,22 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $result = $this->repo->findList(2, 1); $this->assertCount(2, $result); - $result = $this->repo->findList(2, 2); - $this->assertCount(1, $result); + $this->assertCount(1, $this->repo->findList(2, 2)); $result = $this->repo->findList(null, null, null, [], ['visits' => 'DESC']); $this->assertCount(3, $result); $this->assertSame($bar, $result[0]); + + $result = $this->repo->findList(null, null, null, [], null, new DateRange(null, Chronos::now()->subDays(2))); + $this->assertCount(1, $result); + $this->assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2)))); + $this->assertSame($foo2, $result[0]); + + $this->assertCount( + 2, + $this->repo->findList(null, null, null, [], null, new DateRange(Chronos::now()->subDays(2))) + ); + $this->assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2)))); } /** @test */ From 8142801f1f9f9f9512f3eac55f9c20c21a775c7a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 23:03:32 +0100 Subject: [PATCH 074/100] Updated ListShortUrlsAction api test so that it covers filtering use cases --- .../src/Repository/ShortUrlRepository.php | 19 +- .../test-api/Action/ListShortUrlsTest.php | 233 +++++++++++------- .../test-api/Fixtures/ShortUrlsFixture.php | 9 +- 3 files changed, 158 insertions(+), 103 deletions(-) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 1c26c122..ac7b5f50 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -54,15 +54,9 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI private function processOrderByForList(QueryBuilder $qb, $orderBy): array { - // Map public field names to column names - $fieldNameMap = [ - 'originalUrl' => 'longUrl', - 'longUrl' => 'longUrl', - 'shortCode' => 'shortCode', - 'dateCreated' => 'dateCreated', - ]; - $fieldName = is_array($orderBy) ? key($orderBy) : $orderBy; - $order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC'; + $isArray = is_array($orderBy); + $fieldName = $isArray ? key($orderBy) : $orderBy; + $order = $isArray ? $orderBy[$fieldName] : 'ASC'; if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) { $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') @@ -73,6 +67,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return array_column($qb->getQuery()->getResult(), 0); } + // Map public field names to column names + $fieldNameMap = [ + 'originalUrl' => 'longUrl', + 'longUrl' => 'longUrl', + 'shortCode' => 'shortCode', + 'dateCreated' => 'dateCreated', + ]; 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 d2171b3a..eaa13a0a 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -4,107 +4,160 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; +use Cake\Chronos\Chronos; +use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; +use function count; + class ListShortUrlsTest extends ApiTestCase { - /** @test */ - public function shortUrlsAreProperlyListed(): void + private const SHORT_URL_SHLINK = [ + 'shortCode' => 'abc123', + 'shortUrl' => 'http://doma.in/abc123', + 'longUrl' => 'https://shlink.io', + 'dateCreated' => '2018-05-01T00:00:00+00:00', + 'visitsCount' => 3, + 'tags' => ['foo'], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'originalUrl' => 'https://shlink.io', + ]; + private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [ + 'shortCode' => 'custom-with-domain', + 'shortUrl' => 'http://some-domain.com/custom-with-domain', + 'longUrl' => 'https://google.com', + 'dateCreated' => '2018-10-20T00:00:00+00:00', + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'originalUrl' => 'https://google.com', + ]; + private const SHORT_URL_META = [ + 'shortCode' => 'def456', + 'shortUrl' => 'http://doma.in/def456', + 'longUrl' => + 'https://blog.alejandrocelaya.com/2017/12/09' + . '/acmailer-7-0-the-most-important-release-in-a-long-time/', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 2, + 'tags' => ['bar', 'foo'], + 'meta' => [ + 'validSince' => '2020-05-01T00:00:00+00:00', + 'validUntil' => null, + 'maxVisits' => null, + ], + 'originalUrl' => + 'https://blog.alejandrocelaya.com/2017/12/09' + . '/acmailer-7-0-the-most-important-release-in-a-long-time/', + ]; + private const SHORT_URL_CUSTOM_SLUG = [ + 'shortCode' => 'custom', + 'shortUrl' => 'http://doma.in/custom', + 'longUrl' => 'https://shlink.io', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => 2, + ], + 'originalUrl' => 'https://shlink.io', + ]; + private const SHORT_URL_CUSTOM_DOMAIN = [ + 'shortCode' => 'ghi789', + 'shortUrl' => 'http://example.com/ghi789', + 'longUrl' => + 'https://blog.alejandrocelaya.com/2019/04/27' + . '/considerations-to-properly-use-open-source-software-projects/', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'originalUrl' => + 'https://blog.alejandrocelaya.com/2019/04/27' + . '/considerations-to-properly-use-open-source-software-projects/', + ]; + + /** + * @test + * @dataProvider provideFilteredLists + */ + public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls'); + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); $respPayload = $this->getJsonResponsePayload($resp); $this->assertEquals(self::STATUS_OK, $resp->getStatusCode()); $this->assertEquals([ 'shortUrls' => [ - 'data' => [ - [ - 'shortCode' => 'abc123', - 'shortUrl' => 'http://doma.in/abc123', - 'longUrl' => 'https://shlink.io', - 'dateCreated' => '2019-01-01T00:00:00+00:00', - 'visitsCount' => 3, - 'tags' => ['foo'], - 'meta' => [ - 'validSince' => null, - 'validUntil' => null, - 'maxVisits' => null, - ], - 'originalUrl' => 'https://shlink.io', - ], - [ - 'shortCode' => 'def456', - 'shortUrl' => 'http://doma.in/def456', - 'longUrl' => - 'https://blog.alejandrocelaya.com/2017/12/09' - . '/acmailer-7-0-the-most-important-release-in-a-long-time/', - 'dateCreated' => '2019-01-01T00:00:00+00:00', - 'visitsCount' => 2, - 'tags' => ['bar', 'foo'], - 'meta' => [ - 'validSince' => '2020-05-01T00:00:00+00:00', - 'validUntil' => null, - 'maxVisits' => null, - ], - 'originalUrl' => - 'https://blog.alejandrocelaya.com/2017/12/09' - . '/acmailer-7-0-the-most-important-release-in-a-long-time/', - ], - [ - 'shortCode' => 'custom', - 'shortUrl' => 'http://doma.in/custom', - 'longUrl' => 'https://shlink.io', - 'dateCreated' => '2019-01-01T00:00:00+00:00', - 'visitsCount' => 0, - 'tags' => [], - 'meta' => [ - 'validSince' => null, - 'validUntil' => null, - 'maxVisits' => 2, - ], - 'originalUrl' => 'https://shlink.io', - ], - [ - 'shortCode' => 'ghi789', - 'shortUrl' => 'http://example.com/ghi789', - 'longUrl' => - 'https://blog.alejandrocelaya.com/2019/04/27' - . '/considerations-to-properly-use-open-source-software-projects/', - 'dateCreated' => '2019-01-01T00:00:00+00:00', - 'visitsCount' => 0, - 'tags' => [], - 'meta' => [ - 'validSince' => null, - 'validUntil' => null, - 'maxVisits' => null, - ], - 'originalUrl' => - 'https://blog.alejandrocelaya.com/2019/04/27' - . '/considerations-to-properly-use-open-source-software-projects/', - ], - [ - 'shortCode' => 'custom-with-domain', - 'shortUrl' => 'http://some-domain.com/custom-with-domain', - 'longUrl' => 'https://google.com', - 'dateCreated' => '2019-01-01T00:00:00+00:00', - 'visitsCount' => 0, - 'tags' => [], - 'meta' => [ - 'validSince' => null, - 'validUntil' => null, - 'maxVisits' => null, - ], - 'originalUrl' => 'https://google.com', - ], - ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 10, - 'itemsInCurrentPage' => 5, - 'totalItems' => 5, - ], + 'data' => $expectedShortUrls, + 'pagination' => $this->buildPagination(count($expectedShortUrls)), ], ], $respPayload); } + + public function provideFilteredLists(): iterable + { + yield [[], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_CUSTOM_DOMAIN, + ]]; + yield [['orderBy' => 'shortCode'], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, + ]]; + yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_CUSTOM_DOMAIN, + ]]; + yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + ]]; + yield [['tags' => ['foo']], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_META, + ]]; + yield [['tags' => ['bar']], [ + self::SHORT_URL_META, + ]]; + yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ + self::SHORT_URL_SHLINK, + ]]; + yield [['searchTerm' => 'alejandro'], [ + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, + ]]; + } + + private function buildPagination(int $itemsCount): array + { + return [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 10, + 'itemsInCurrentPage' => $itemsCount, + 'totalItems' => $itemsCount, + ]; + } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 3282e575..62ec00a9 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -21,7 +21,8 @@ class ShortUrlsFixture extends AbstractFixture public function load(ObjectManager $manager): void { $abcShortUrl = $this->setShortUrlDate( - new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123'])) + new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123'])), + Chronos::parse('2018-05-01') ); $manager->persist($abcShortUrl); @@ -46,7 +47,7 @@ class ShortUrlsFixture extends AbstractFixture $withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://google.com', ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']) - )); + ), Chronos::parse('2018-10-20')); $manager->persist($withDomainAndSlugShortUrl); $manager->flush(); @@ -55,12 +56,12 @@ class ShortUrlsFixture extends AbstractFixture $this->addReference('def456_short_url', $defShortUrl); } - private function setShortUrlDate(ShortUrl $shortUrl): ShortUrl + private function setShortUrlDate(ShortUrl $shortUrl, ?Chronos $date = null): ShortUrl { $ref = new ReflectionObject($shortUrl); $dateProp = $ref->getProperty('dateCreated'); $dateProp->setAccessible(true); - $dateProp->setValue($shortUrl, Chronos::create(2019, 1, 1, 0, 0, 0)); + $dateProp->setValue($shortUrl, $date ?? Chronos::parse('2019-01-01')); return $shortUrl; } From 56165791310121c5ffdefd5b279ad37dc79cfad8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 09:59:54 +0100 Subject: [PATCH 075/100] Added startDate and endDate params to ListShortUrlsCommand --- .../src/Command/Api/GenerateKeyCommand.php | 2 +- .../src/Command/ShortUrl/GetVisitsCommand.php | 51 +++++----- .../Command/ShortUrl/ListShortUrlsCommand.php | 47 ++++++++-- .../Util/AbstractWithDateRangeCommand.php | 54 +++++++++++ .../Command/ShortUrl/GetVisitsCommandTest.php | 27 +++++- .../ShortUrl/ListShortUrlsCommandTest.php | 92 +++++++++++++++---- 6 files changed, 215 insertions(+), 58 deletions(-) create mode 100644 module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index f35fa012..bbe86a51 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -36,7 +36,7 @@ class GenerateKeyCommand extends Command ->addOption( 'expirationDate', 'e', - InputOption::VALUE_OPTIONAL, + InputOption::VALUE_REQUIRED, 'The date in which the API key should expire. Use any valid PHP format.' ); } diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 7873fba6..416c1bfb 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -5,24 +5,25 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; +use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Zend\Stdlib\ArrayUtils; +use Throwable; -use function array_map; +use function Functional\map; use function Functional\select_keys; +use function sprintf; -class GetVisitsCommand extends Command +class GetVisitsCommand extends AbstractWithDateRangeCommand { public const NAME = 'short-url:visits'; private const ALIASES = ['shortcode:visits', 'short-code:visits']; @@ -36,25 +37,23 @@ class GetVisitsCommand extends Command parent::__construct(); } - protected function configure(): void + protected function doConfigure(): void { $this ->setName(self::NAME) ->setAliases(self::ALIASES) ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get') - ->addOption( - 'startDate', - 's', - InputOption::VALUE_OPTIONAL, - 'Allows to filter visits, returning only those older than start date' - ) - ->addOption( - 'endDate', - 'e', - InputOption::VALUE_OPTIONAL, - 'Allows to filter visits, returning only those newer than end date' - ); + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get'); + } + + protected function getStartDateDesc(): string + { + return 'Allows to filter visits, returning only those older than start date'; + } + + protected function getEndDateDesc(): string + { + return 'Allows to filter visits, returning only those newer than end date'; } protected function interact(InputInterface $input, OutputInterface $output): void @@ -74,24 +73,18 @@ class GetVisitsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { $shortCode = $input->getArgument('shortCode'); - $startDate = $this->getDateOption($input, 'startDate'); - $endDate = $this->getDateOption($input, 'endDate'); + $startDate = $this->getDateOption($input, $output, 'startDate'); + $endDate = $this->getDateOption($input, $output, 'endDate'); $paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate))); - $visits = ArrayUtils::iteratorToArray($paginator->getCurrentItems()); - $rows = array_map(function (Visit $visit) { + $rows = map($paginator->getCurrentItems(), function (Visit $visit) { $rowData = $visit->jsonSerialize(); $rowData['country'] = $visit->getVisitLocation()->getCountryName(); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); - }, $visits); + }); ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); + return ExitCodes::EXIT_SUCCESS; } - - private function getDateOption(InputInterface $input, $key) - { - $value = $input->getOption($key); - return ! empty($value) ? Chronos::parse($value) : $value; - } } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 3d9b528d..5871cc76 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,14 +4,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Cake\Chronos\Chronos; +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\Rest\DataTransformerInterface; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -26,7 +28,7 @@ use function explode; use function implode; use function sprintf; -class ListShortUrlsCommand extends Command +class ListShortUrlsCommand extends AbstractWithDateRangeCommand { use PaginatorUtilsTrait; @@ -53,7 +55,7 @@ class ListShortUrlsCommand extends Command $this->domainConfig = $domainConfig; } - protected function configure(): void + protected function doConfigure(): void { $this ->setName(self::NAME) @@ -68,7 +70,7 @@ class ListShortUrlsCommand extends Command ) ->addOption( 'searchTerm', - 's', + 'st', InputOption::VALUE_REQUIRED, 'A query used to filter results by searching for it on the longUrl and shortCode fields' ) @@ -87,6 +89,16 @@ class ListShortUrlsCommand extends Command ->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not'); } + protected function getStartDateDesc(): string + { + return 'Allows to filter short URLs, returning only those created after "startDate"'; + } + + protected function getEndDateDesc(): string + { + return 'Allows to filter short URLs, returning only those created before "endDate"'; + } + protected function execute(InputInterface $input, OutputInterface $output): ?int { $io = new SymfonyStyle($input, $output); @@ -95,10 +107,23 @@ class ListShortUrlsCommand extends Command $tags = $input->getOption('tags'); $tags = ! empty($tags) ? explode(',', $tags) : []; $showTags = (bool) $input->getOption('showTags'); + $startDate = $this->getDateOption($input, $output, 'startDate'); + $endDate = $this->getDateOption($input, $output, 'endDate'); + $transformer = new ShortUrlDataTransformer($this->domainConfig); do { - $result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer); + $result = $this->renderPage( + $input, + $output, + $page, + $searchTerm, + $tags, + $showTags, + $startDate, + $endDate, + $transformer + ); $page++; $continue = $this->isLastPage($result) @@ -108,6 +133,7 @@ class ListShortUrlsCommand extends Command $io->newLine(); $io->success('Short URLs properly listed'); + return ExitCodes::EXIT_SUCCESS; } @@ -118,9 +144,17 @@ class ListShortUrlsCommand extends Command ?string $searchTerm, array $tags, bool $showTags, + ?Chronos $startDate, + ?Chronos $endDate, DataTransformerInterface $transformer ): Paginator { - $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); + $result = $this->shortUrlService->listShortUrls( + $page, + $searchTerm, + $tags, + $this->processOrderBy($input), + new DateRange($startDate, $endDate) + ); $headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count']; if ($showTags) { @@ -143,6 +177,7 @@ class ListShortUrlsCommand extends Command $result, 'Page %s of %s' )); + return $result; } diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php new file mode 100644 index 00000000..c6b10be6 --- /dev/null +++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php @@ -0,0 +1,54 @@ +doConfigure(); + $this + ->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc()) + ->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc()); + } + + protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos + { + $value = $input->getOption($key); + if (empty($value)) { + return null; + } + + try { + return Chronos::parse($value); + } catch (Throwable $e) { + $output->writeln(sprintf( + '> Ignored provided "%s" since its value "%s" is not a valid date. <', + $key, + $value + )); + + if ($output->isVeryVerbose()) { + $this->getApplication()->renderThrowable($e, $output); + } + + return null; + } + } + + abstract protected function doConfigure(): void; + + abstract protected function getStartDateDesc(): string; + abstract protected function getEndDateDesc(): string; +} diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index a61dd7d4..e2ea29d1 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -22,6 +22,8 @@ use Symfony\Component\Console\Tester\CommandTester; use Zend\Paginator\Adapter\ArrayAdapter; use Zend\Paginator\Paginator; +use function sprintf; + class GetVisitsCommandTest extends TestCase { /** @var CommandTester */ @@ -39,7 +41,7 @@ class GetVisitsCommandTest extends TestCase } /** @test */ - public function noDateFlagsTriesToListWithoutDateRange() + public function noDateFlagsTriesToListWithoutDateRange(): void { $shortCode = 'abc123'; $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn( @@ -50,7 +52,7 @@ class GetVisitsCommandTest extends TestCase } /** @test */ - public function providingDateFlagsTheListGetsFiltered() + public function providingDateFlagsTheListGetsFiltered(): void { $shortCode = 'abc123'; $startDate = '2016-01-01'; @@ -69,6 +71,27 @@ class GetVisitsCommandTest extends TestCase ]); } + /** @test */ + public function providingInvalidDatesPrintsWarning(): void + { + $shortCode = 'abc123'; + $startDate = 'foo'; + $info = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange())) + ->willReturn(new Paginator(new ArrayAdapter([]))); + + $this->commandTester->execute([ + 'shortCode' => $shortCode, + '--startDate' => $startDate, + ]); + $output = $this->commandTester->getDisplay(); + + $info->shouldHaveBeenCalledOnce(); + $this->assertStringContainsString( + sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate), + $output + ); + } + /** @test */ public function outputIsProperlyGenerated(): void { diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 6ad96ed3..71babc47 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; +use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; +use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Symfony\Component\Console\Application; @@ -15,6 +17,8 @@ use Symfony\Component\Console\Tester\CommandTester; use Zend\Paginator\Adapter\ArrayAdapter; use Zend\Paginator\Paginator; +use function explode; + class ListShortUrlsCommandTest extends TestCase { /** @var CommandTester */ @@ -32,17 +36,7 @@ class ListShortUrlsCommandTest extends TestCase } /** @test */ - public function noInputCallsListJustOnce() - { - $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) - ->shouldBeCalledOnce(); - - $this->commandTester->setInputs(['n']); - $this->commandTester->execute([]); - } - - /** @test */ - public function loadingMorePagesCallsListMoreTimes() + public function loadingMorePagesCallsListMoreTimes(): void { // The paginator will return more than one page $data = []; @@ -64,7 +58,7 @@ class ListShortUrlsCommandTest extends TestCase } /** @test */ - public function havingMorePagesButAnsweringNoCallsListJustOnce() + public function havingMorePagesButAnsweringNoCallsListJustOnce(): void { // The paginator will return more than one page $data = []; @@ -72,8 +66,9 @@ class ListShortUrlsCommandTest extends TestCase $data[] = new ShortUrl('url_' . $i); } - $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter($data))) - ->shouldBeCalledOnce(); + $this->shortUrlService->listShortUrls(1, null, [], null, new DateRange()) + ->willReturn(new Paginator(new ArrayAdapter($data))) + ->shouldBeCalledOnce(); $this->commandTester->setInputs(['n']); $this->commandTester->execute([]); @@ -89,25 +84,82 @@ class ListShortUrlsCommandTest extends TestCase } /** @test */ - public function passingPageWillMakeListStartOnThatPage() + public function passingPageWillMakeListStartOnThatPage(): void { $page = 5; - $this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) - ->shouldBeCalledOnce(); + $this->shortUrlService->listShortUrls($page, null, [], null, new DateRange()) + ->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); $this->commandTester->execute(['--page' => $page]); } /** @test */ - public function ifTagsFlagIsProvidedTagsColumnIsIncluded() + public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void { - $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) - ->shouldBeCalledOnce(); + $this->shortUrlService->listShortUrls(1, null, [], null, new DateRange()) + ->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); $this->commandTester->execute(['--showTags' => true]); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Tags', $output); } + + /** + * @test + * @dataProvider provideArgs + */ + public function serviceIsInvokedWithProvidedArgs( + array $commandArgs, + ?int $page, + ?string $searchTerm, + array $tags, + ?DateRange $dateRange + ): void { + $listShortUrls = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, null, $dateRange) + ->willReturn(new Paginator(new ArrayAdapter())); + + $this->commandTester->setInputs(['n']); + $this->commandTester->execute($commandArgs); + + $listShortUrls->shouldHaveBeenCalledOnce(); + } + + public function provideArgs(): iterable + { + yield [[], 1, null, [], new DateRange()]; + yield [['--page' => $page = 3], $page, null, [], new DateRange()]; + yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, [], new DateRange()]; + yield [ + ['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], + $page, + $searchTerm, + explode(',', $tags), + new DateRange(), + ]; + yield [ + ['--startDate' => $startDate = '2019-01-01'], + 1, + null, + [], + new DateRange(Chronos::parse($startDate)), + ]; + yield [ + ['--endDate' => $endDate = '2020-05-23'], + 1, + null, + [], + new DateRange(null, Chronos::parse($endDate)), + ]; + yield [ + ['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'], + 1, + null, + [], + new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)), + ]; + } } From 4b113e578125459f172bcfa3fd700f08be245790 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 10:06:18 +0100 Subject: [PATCH 076/100] Added tests covering how orderBy is parsed on ListShortUrlsCommand --- .../Command/ShortUrl/ListShortUrlsCommand.php | 10 +++++--- .../ShortUrl/ListShortUrlsCommandTest.php | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 5871cc76..52918afa 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -109,12 +109,12 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $showTags = (bool) $input->getOption('showTags'); $startDate = $this->getDateOption($input, $output, 'startDate'); $endDate = $this->getDateOption($input, $output, 'endDate'); + $orderBy = $this->processOrderBy($input); $transformer = new ShortUrlDataTransformer($this->domainConfig); do { $result = $this->renderPage( - $input, $output, $page, $searchTerm, @@ -122,6 +122,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $showTags, $startDate, $endDate, + $orderBy, $transformer ); $page++; @@ -138,7 +139,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand } private function renderPage( - InputInterface $input, OutputInterface $output, int $page, ?string $searchTerm, @@ -146,13 +146,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand bool $showTags, ?Chronos $startDate, ?Chronos $endDate, + $orderBy, DataTransformerInterface $transformer ): Paginator { $result = $this->shortUrlService->listShortUrls( $page, $searchTerm, $tags, - $this->processOrderBy($input), + $orderBy, new DateRange($startDate, $endDate) ); @@ -181,6 +182,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand return $result; } + /** + * @return array|string|null + */ private function processOrderBy(InputInterface $input) { $orderBy = $input->getOption('orderBy'); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 71babc47..0bf3bfab 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -162,4 +162,27 @@ class ListShortUrlsCommandTest extends TestCase new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)), ]; } + + /** + * @test + * @dataProvider provideOrderBy + */ + public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void + { + $listShortUrls = $this->shortUrlService->listShortUrls(1, null, [], $expectedOrderBy, new DateRange()) + ->willReturn(new Paginator(new ArrayAdapter())); + + $this->commandTester->setInputs(['n']); + $this->commandTester->execute($commandArgs); + + $listShortUrls->shouldHaveBeenCalledOnce(); + } + + public function provideOrderBy(): iterable + { + yield [[], null]; + yield [['--orderBy' => 'foo'], 'foo']; + yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']]; + yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']]; + } } From f7d09bf1732d4a8d214c382e1947a97949c4d380 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 10:11:12 +0100 Subject: [PATCH 077/100] Slight refactoring on ListSHortUrlsCommand --- .../src/Command/ShortUrl/GetVisitsCommand.php | 4 --- .../Command/ShortUrl/ListShortUrlsCommand.php | 27 +++++-------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 416c1bfb..7a51ac0b 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Cake\Chronos\Chronos; use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; @@ -14,14 +13,11 @@ use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Throwable; use function Functional\map; use function Functional\select_keys; -use function sprintf; class GetVisitsCommand extends AbstractWithDateRangeCommand { diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 52918afa..01080189 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -9,7 +9,6 @@ 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\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -45,14 +44,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand /** @var ShortUrlServiceInterface */ private $shortUrlService; - /** @var array */ - private $domainConfig; + /** @var ShortUrlDataTransformer */ + private $transformer; public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) { parent::__construct(); $this->shortUrlService = $shortUrlService; - $this->domainConfig = $domainConfig; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } protected function doConfigure(): void @@ -102,6 +101,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand protected function execute(InputInterface $input, OutputInterface $output): ?int { $io = new SymfonyStyle($input, $output); + $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('searchTerm'); $tags = $input->getOption('tags'); @@ -111,20 +111,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $endDate = $this->getDateOption($input, $output, 'endDate'); $orderBy = $this->processOrderBy($input); - $transformer = new ShortUrlDataTransformer($this->domainConfig); - do { - $result = $this->renderPage( - $output, - $page, - $searchTerm, - $tags, - $showTags, - $startDate, - $endDate, - $orderBy, - $transformer - ); + $result = $this->renderPage($output, $page, $searchTerm, $tags, $showTags, $startDate, $endDate, $orderBy); $page++; $continue = $this->isLastPage($result) @@ -146,8 +134,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand bool $showTags, ?Chronos $startDate, ?Chronos $endDate, - $orderBy, - DataTransformerInterface $transformer + $orderBy ): Paginator { $result = $this->shortUrlService->listShortUrls( $page, @@ -164,7 +151,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $rows = []; foreach ($result as $row) { - $shortUrl = $transformer->transform($row); + $shortUrl = $this->transformer->transform($row); if ($showTags) { $shortUrl['tags'] = implode(', ', $shortUrl['tags']); } else { From 524914fd35a5e3719213018534a3196de3ef26da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 10:14:18 +0100 Subject: [PATCH 078/100] Updated changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6fc5f19..27e08896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this > After Shlink v2 is released, both API versions will behave like API v2. +* [#575](https://github.com/shlinkio/shlink/issues/575) Added support to filter short URL lists by date ranges. + + * The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params. + * The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided. + #### Changed * [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. From 18f0fb556ab02b26414b1f63fb4cf894d7cd35ec Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 16 Dec 2019 09:02:28 +0100 Subject: [PATCH 079/100] Added project logo to readme file --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5eac9951..b975e4de 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ +

+ # Shlink [![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink) From 748786d59918258f42e15182e4c2c76704a0af57 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 13:44:22 +0100 Subject: [PATCH 080/100] Updated project header image --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index b975e4de..e901f8ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ -

- -

- -# Shlink +![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/master/public/images/shlink-hero.png) [![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink) [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master) From 0152f6fa1ac016f24c78b826b8ef62b32119214d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 17 Dec 2019 21:20:24 +0100 Subject: [PATCH 081/100] Added dev php config and removed .env dependencies --- .env.dist | 10 ---------- .gitattributes | 1 - .gitignore | 1 - composer.json | 1 - config/autoload/entity-manager.global.php | 6 +++--- config/autoload/entity-manager.local.php.dist | 5 +++++ config/autoload/url-shortener.global.php | 6 ++---- config/autoload/url-shortener.local.php.dist | 14 ++++++++++++++ config/container.php | 9 --------- config/test/bootstrap_api_tests.php | 8 -------- config/test/bootstrap_db_tests.php | 8 -------- data/infra/php.ini | 7 ++++++- docker-compose.yml | 3 ++- 13 files changed, 32 insertions(+), 47 deletions(-) delete mode 100644 .env.dist create mode 100644 config/autoload/url-shortener.local.php.dist diff --git a/.env.dist b/.env.dist deleted file mode 100644 index daf74cbb..00000000 --- a/.env.dist +++ /dev/null @@ -1,10 +0,0 @@ -# Application -APP_ENV= -SECRET_KEY= -SHORTENED_URL_SCHEMA= -SHORTENED_URL_HOSTNAME= - -# Database -DB_USER= -DB_PASSWORD= -DB_NAME= diff --git a/.gitattributes b/.gitattributes index df9095a4..80102b6f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,7 +9,6 @@ /module/PreviewGenerator/test-db export-ignore /module/Rest/test export-ignore /module/Rest/test-api export-ignore -.env.dist export-ignore .gitattributes export-ignore .gitignore export-ignore .phpstorm.meta.php export-ignore diff --git a/.gitignore b/.gitignore index 7b14364c..ab121a93 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build composer.lock composer.phar vendor/ -.env data/database.sqlite data/shlink-tests.db data/GeoLite2-City.mmdb diff --git a/composer.json b/composer.json index 84290c2a..c1563613 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,6 @@ "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.0.0", "shlinkio/shlink-test-utils": "^1.2", - "symfony/dotenv": "^5.0", "symfony/var-dumper": "^5.0" }, "autoload": { diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index bd847f5a..561579f1 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -11,9 +11,9 @@ return [ 'proxies_dir' => 'data/proxies', ], 'connection' => [ - 'user' => env('DB_USER'), - 'password' => env('DB_PASSWORD'), - 'dbname' => env('DB_NAME', 'shlink'), + 'user' => '', + 'password' => '', + 'dbname' => 'shlink', 'charset' => 'utf8', ], ], diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index cb53a6ae..3c38fb82 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -1,8 +1,13 @@ [ 'connection' => [ + 'user' => 'root', + 'password' => 'root', 'driver' => 'pdo_mysql', 'host' => 'shlink_db', 'driverOptions' => [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 7c7e19de..46e12593 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -2,14 +2,12 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; - return [ 'url_shortener' => [ 'domain' => [ - 'schema' => env('SHORTENED_URL_SCHEMA', 'http'), - 'hostname' => env('SHORTENED_URL_HOSTNAME'), + 'schema' => 'https', + 'hostname' => '', ], 'validate_url' => true, ], diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist new file mode 100644 index 00000000..11155ba7 --- /dev/null +++ b/config/autoload/url-shortener.local.php.dist @@ -0,0 +1,14 @@ + [ + 'domain' => [ + 'schema' => 'http', + 'hostname' => '', + ], + ], + +]; diff --git a/config/container.php b/config/container.php index f2c1d0c7..3df59ed9 100644 --- a/config/container.php +++ b/config/container.php @@ -2,21 +2,12 @@ declare(strict_types=1); -use Symfony\Component\Dotenv\Dotenv; use Zend\ServiceManager\ServiceManager; chdir(dirname(__DIR__)); require 'vendor/autoload.php'; -// If the Dotenv class exists, load env vars and enable errors -if (class_exists(Dotenv::class)) { - error_reporting(E_ALL); - ini_set('display_errors', '1'); - $dotenv = new Dotenv(true); - $dotenv->load(__DIR__ . '/../.env'); -} - // Build container $config = require __DIR__ . '/config.php'; $container = new ServiceManager($config['dependencies']); diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index 562986c1..3605427c 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,14 +7,6 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; -use function file_exists; -use function touch; - -// Create an empty .env file -if (! file_exists('.env')) { - touch('.env'); -} - /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); diff --git a/config/test/bootstrap_db_tests.php b/config/test/bootstrap_db_tests.php index e5e88e20..9f14c38d 100644 --- a/config/test/bootstrap_db_tests.php +++ b/config/test/bootstrap_db_tests.php @@ -6,14 +6,6 @@ namespace Shlinkio\Shlink\TestUtils; use Psr\Container\ContainerInterface; -use function file_exists; -use function touch; - -// Create an empty .env file -if (! file_exists('.env')) { - touch('.env'); -} - /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $container->get(Helper\TestHelper::class)->createTestDb(); diff --git a/data/infra/php.ini b/data/infra/php.ini index 9c1e3f01..5ef7b7ea 100644 --- a/data/infra/php.ini +++ b/data/infra/php.ini @@ -1 +1,6 @@ -date.timezone = Europe/Madrid +display_errors=On +error_reporting=-1 +memory_limit=-1 +log_errors_max_len=0 +zend.assertions=1 +assert.exception=1 diff --git a/docker-compose.yml b/docker-compose.yml index 811eec69..99cc93fb 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.15.9-alpine + image: nginx:1.17.6-alpine ports: - "8000:80" volumes: @@ -37,6 +37,7 @@ services: - "9001:9001" volumes: - ./:/home/shlink + - ./data/infra/php.ini:/usr/local/etc/php/php.ini links: - shlink_db - shlink_db_postgres From 0747137679e7d46c6113449bdfca0b141fb8cbd1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 18 Dec 2019 14:54:48 +0100 Subject: [PATCH 082/100] Added php ini config for docker image --- Dockerfile | 3 ++- docker/config/php.ini | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 docker/config/php.ini diff --git a/Dockerfile b/Dockerfile index eae81a5a..f24dd289 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM php:7.3.11-alpine3.10 LABEL maintainer="Alejandro Celaya " -ARG SHLINK_VERSION=1.20.0 +ARG SHLINK_VERSION=1.20.2 ENV SHLINK_VERSION ${SHLINK_VERSION} ENV SWOOLE_VERSION 4.4.12 ENV COMPOSER_VERSION 1.9.1 @@ -52,5 +52,6 @@ VOLUME /etc/shlink/config/params # Copy config specific for the image COPY docker/docker-entrypoint.sh docker-entrypoint.sh COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php +COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] diff --git a/docker/config/php.ini b/docker/config/php.ini new file mode 100644 index 00000000..f6c718d0 --- /dev/null +++ b/docker/config/php.ini @@ -0,0 +1,3 @@ +log_errors_max_len=0 +zend.assertions=1 +assert.exception=1 From 03eeef7f5278df576980fe2e9135e5c9fda06caf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 18 Dec 2019 14:55:37 +0100 Subject: [PATCH 083/100] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e08896..66a30779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. * [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%. +* [#557](https://github.com/shlinkio/shlink/issues/557) Added a few php.ini configs for development and production docker images. #### Deprecated From 4334ea295d5ef8aee3b0093aef6b02a08f4a7dd3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 18 Dec 2019 15:00:39 +0100 Subject: [PATCH 084/100] Added missing local config --- config/autoload/url-shortener.local.php.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 11155ba7..c686137f 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -7,7 +7,7 @@ return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => '', + 'hostname' => 'localhost:8080', ], ], From 75b91dc26b056f4457f9c103e356bd2d16cab80b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 18 Dec 2019 15:26:23 +0100 Subject: [PATCH 085/100] Hardcoded different creation dates for fixture short URLs to avoid random API test failures --- module/Rest/test-api/Action/ListShortUrlsTest.php | 6 +++--- module/Rest/test-api/Fixtures/ShortUrlsFixture.php | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index eaa13a0a..0952a627 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -46,7 +46,7 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09' . '/acmailer-7-0-the-most-important-release-in-a-long-time/', - 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'dateCreated' => '2019-01-01T00:00:10+00:00', 'visitsCount' => 2, 'tags' => ['bar', 'foo'], 'meta' => [ @@ -62,7 +62,7 @@ class ListShortUrlsTest extends ApiTestCase 'shortCode' => 'custom', 'shortUrl' => 'http://doma.in/custom', 'longUrl' => 'https://shlink.io', - 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'dateCreated' => '2019-01-01T00:00:20+00:00', 'visitsCount' => 0, 'tags' => [], 'meta' => [ @@ -78,7 +78,7 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27' . '/considerations-to-properly-use-open-source-software-projects/', - 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'dateCreated' => '2019-01-01T00:00:30+00:00', 'visitsCount' => 0, 'tags' => [], 'meta' => [ diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 62ec00a9..cc359047 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -22,32 +22,32 @@ class ShortUrlsFixture extends AbstractFixture { $abcShortUrl = $this->setShortUrlDate( new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123'])), - Chronos::parse('2018-05-01') + '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::createFromParams(Chronos::parse('2020-05-01'), null, 'def456') - )); + ), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); $customShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://shlink.io', ShortUrlMeta::createFromParams(null, null, 'custom', 2) - )); + ), '2019-01-01 00:00:20'); $manager->persist($customShortUrl); $withDomainShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', ShortUrlMeta::createFromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']) - )); + ), '2019-01-01 00:00:30'); $manager->persist($withDomainShortUrl); $withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://google.com', ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']) - ), Chronos::parse('2018-10-20')); + ), '2018-10-20'); $manager->persist($withDomainAndSlugShortUrl); $manager->flush(); @@ -56,12 +56,12 @@ class ShortUrlsFixture extends AbstractFixture $this->addReference('def456_short_url', $defShortUrl); } - private function setShortUrlDate(ShortUrl $shortUrl, ?Chronos $date = null): ShortUrl + private function setShortUrlDate(ShortUrl $shortUrl, string $date): ShortUrl { $ref = new ReflectionObject($shortUrl); $dateProp = $ref->getProperty('dateCreated'); $dateProp->setAccessible(true); - $dateProp->setValue($shortUrl, $date ?? Chronos::parse('2019-01-01')); + $dateProp->setValue($shortUrl, Chronos::parse($date)); return $shortUrl; } From 9c06803a319b137ea38f3f676e67ae6e7ffdbd9c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 19 Dec 2019 11:39:41 +0100 Subject: [PATCH 086/100] Updated documentation, adding more sub-sections and trying to explain everything even better --- README.md | 319 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 167 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index e901f8ec..5f86aeb2 100644 --- a/README.md +++ b/README.md @@ -12,26 +12,36 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u ## Table of Contents - [Installation](#installation) + - [Download](#download) + - [Configure](#configure) + - [Serve](#serve) + - [Bonus](#bonus) - [Update to new version](#update-to-new-version) - [Using a docker image](#using-a-docker-image) - [Using shlink](#using-shlink) - - [Shlink CLI Help](#shlink-cli-help) + - [Shlink CLI Help](#shlink-cli-help) ## Installation -First make sure the host where you are going to run shlink fulfills these requirements: +> These are the steps needed to install Shlink if you plan to manually host it. +> +> Alternatively, you can use the official docker image. If that's your intention, jump directly to [Using a docker image](#using-a-docker-image) + +First, make sure the host where you are going to run shlink fulfills these requirements: * PHP 7.2 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled. * MySQL, MariaDB, PostgreSQL or SQLite. * The web server of your choice with PHP integration (Apache or Nginx recommended). -Then, you will need a built version of the project. There are a few ways to get it. +### Download + +In order to run Shlink, you will need a built version of the project. There are two ways to get it. * **Using a dist file** The easiest way to install shlink is by using one of the pre-bundled distributable packages. - Just 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_x.x.x_dist.zip` file you will find there. Finally, decompress the file in the location of your choice. @@ -43,158 +53,159 @@ Then, you will need a built version of the project. There are a few ways to get * 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). - After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory. + 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. - This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching generated dist file to it. + > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching the generated dist file to it. -Despite how you built the project, you are going to need to install it now, by following these steps: +### Configure + +Despite how you built the project, you now need to configure it, by following these steps: * If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice. * Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information. * Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.** -* Expose shlink to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server. - - * **Using a web server:** - - For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache. - - *Nginx:* - - ```nginx - server { - server_name doma.in; - listen 80; - root /path/to/shlink/public; - index index.php; - charset utf-8; - - location / { - try_files $uri $uri/ /index.php$is_args$args; - } - - location ~ \.php$ { - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; - fastcgi_index index.php; - include fastcgi.conf; - } - - location ~ /\.ht { - deny all; - } - } - ``` - - *Apache:* - - ```apache - - ServerName doma.in - DocumentRoot "/path/to/shlink/public" - - - Options FollowSymLinks Includes ExecCGI - AllowOverride all - Order allow,deny - Allow from all - - - ``` - - * **Using swoole:** - - First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`. - - Once installed, it's actually pretty easy to get shlink up and running with swoole. Just run `./vendor/bin/zend-expressive-swoole start -d` and you will get shlink running on port 8080. - - However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted. - - For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation: - - ```bash - #!/bin/bash - ### BEGIN INIT INFO - # Provides: shlink_swoole - # Required-Start: $local_fs $network $named $time $syslog - # Required-Stop: $local_fs $network $named $time $syslog - # Default-Start: 2 3 4 5 - # Default-Stop: 0 1 6 - # Description: Shlink non-blocking server with swoole - ### END INIT INFO - - SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start - RUNAS=root - - PIDFILE=/var/run/shlink_swoole.pid - LOGDIR=/var/log/shlink - LOGFILE=${LOGDIR}/shlink_swoole.log - - start() { - if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with swoole already running' >&2 - return 1 - fi - echo 'Starting shlink with swoole' >&2 - mkdir -p "$LOGDIR" - touch "$LOGFILE" - local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!" - su -c "$CMD" $RUNAS > "$PIDFILE" - echo 'Shlink started' >&2 - } - - stop() { - if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with swoole not running' >&2 - return 1 - fi - echo 'Stopping shlink with swoole' >&2 - kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE" - echo 'Shlink stopped' >&2 - } - - case "$1" in - start) - start - ;; - stop) - stop - ;; - restart) - stop - start - ;; - *) - echo "Usage: $0 {start|stop|restart}" - esac - ``` - - Then run these commands to enable the service and start it: - - * `sudo chmod +x /etc/init.d/shlink_swoole` - * `sudo update-rc.d shlink_swoole defaults` - * `sudo update-rc.d shlink_swoole enable` - * `/etc/init.d/shlink_swoole start` - - Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)). - * Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API. -* Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs. -**Bonus** +### Serve -There are a couple of time-consuming tasks that shlink expects you to do manually, or at least it is recommended, since it will improve runtime performance. +Once Shlink is configured, you need to expose it to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server. -Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs): +* **Using a web server:** -* **For shlink older than 1.18.0 or not using swoole as the web server**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate` + For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache. + + *Nginx:* + + ```nginx + server { + server_name doma.in; + listen 80; + root /path/to/shlink/public; + index index.php; + charset utf-8; + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; + fastcgi_index index.php; + include fastcgi.conf; + } + + location ~ /\.ht { + deny all; + } + } + ``` + + *Apache:* + + ```apache + + ServerName doma.in + DocumentRoot "/path/to/shlink/public" + + + Options FollowSymLinks Includes ExecCGI + AllowOverride all + Order allow,deny + Allow from all + + + ``` + +* **Using swoole:** + + First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`. + + Once installed, it's actually pretty easy to get shlink up and running with swoole. Run `./vendor/bin/zend-expressive-swoole start -d` and you will get shlink running on port 8080. + + However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted. + + For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation: + + ```bash + #!/bin/bash + ### BEGIN INIT INFO + # Provides: shlink_swoole + # Required-Start: $local_fs $network $named $time $syslog + # Required-Stop: $local_fs $network $named $time $syslog + # Default-Start: 2 3 4 5 + # Default-Stop: 0 1 6 + # Description: Shlink non-blocking server with swoole + ### END INIT INFO + + SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start + RUNAS=root + + PIDFILE=/var/run/shlink_swoole.pid + LOGDIR=/var/log/shlink + LOGFILE=${LOGDIR}/shlink_swoole.log + + start() { + if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then + echo 'Shlink with swoole already running' >&2 + return 1 + fi + echo 'Starting shlink with swoole' >&2 + mkdir -p "$LOGDIR" + touch "$LOGFILE" + local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!" + su -c "$CMD" $RUNAS > "$PIDFILE" + echo 'Shlink started' >&2 + } + + stop() { + if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then + echo 'Shlink with swoole not running' >&2 + return 1 + fi + echo 'Stopping shlink with swoole' >&2 + kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE" + echo 'Shlink stopped' >&2 + } + + case "$1" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + start + ;; + *) + echo "Usage: $0 {start|stop|restart}" + esac + ``` + + Then run these commands to enable the service and start it: + + * `sudo chmod +x /etc/init.d/shlink_swoole` + * `sudo update-rc.d shlink_swoole defaults` + * `sudo update-rc.d shlink_swoole enable` + * `/etc/init.d/shlink_swoole start` + + Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)). + +Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs. + +### Bonus + +Depending on the shlink version you installed and how you serve it, there are a couple of time-consuming tasks that shlink expects you to do manually, or at least it is recommended, since it will improve runtime performance. + +Those tasks can be performed using shlink's CLI tool, so it should be easy to schedule them to be run in the background (for example, using cron jobs): + +* **For shlink older than 1.18.0 or not using swoole to serve it**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate` If you don't run this command regularly, the stats will say all visits come from *unknown* locations. -* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews` - - Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site. - - > **Important!** Generating previews is considered deprecated and the feature will be removed in Shlink v2. + > If you serve Shlink with swoole and use v1.18.0 at least, visit location is automatically scheduled by Shlink just after the visit occurs, using swoole's task system. * **For shlink older than v1.17.0**: Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db` @@ -202,24 +213,28 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday. -*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.* + > You don't need this if you use Shlink v1.17.0 or newer, since now it downloads/updates the geolocation database automatically just before trying to use it. -> In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews. +* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews` + + Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site. + + > **Important!** Generating previews is considered deprecated and the feature will be removed in Shlink v2. + +*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.* ## Update to new version -When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps: +When a new Shlink version is available, you don't need to repeat the entire process. Instead, follow these steps: 1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`). -2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`). -3. Run the `bin/update` script in the new version's directory to migrate your configuration over. +2. Download and extract the new version of Shlink, and set the directory name to that of the old version (ie. `shlink`). +3. Run the `bin/update` script in the new version's directory to migrate your configuration over. You will be asked to provide the path to the old instance (ie. `shlink-old`). 4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`. -The `bin/update` script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some assets shlink needs to work. +The `bin/update` will use the location from previous shlink version to import the configuration. It will then update the database and generate some assets shlink needs to work. -Right now, it does not import cached info (like website previews), but it will. For now you will need to regenerate them again. - -**Important!** It is recommended that you don't skip any version when using this process. The update gets better on every version, but older versions might make assumptions. +**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions. ## Using a docker image @@ -237,7 +252,7 @@ Once shlink is installed, there are two main ways to interact with it: It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory. -* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found [here](https://shlink.io/swagger-ui/index.html). +* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal. However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too. From 7c52d0ec19cb78395d86c0fcc06c8a271cc5fbf6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Dec 2019 16:02:12 +0100 Subject: [PATCH 087/100] Required at least guzzle 6.5.1 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c1563613..32105e23 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "endroid/qr-code": "^3.6", "firebase/php-jwt": "^4.0", "geoip2/geoip2": "^2.9", - "guzzlehttp/guzzle": "^6.5", + "guzzlehttp/guzzle": "^6.5.1", "lstrojny/functional-php": "^1.9", "mikehaertl/phpwkhtmltopdf": "^2.2", "monolog/monolog": "^2.0", From d67321f187c140542e4b608abf588123a0146f72 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 21 Dec 2019 16:09:29 +0100 Subject: [PATCH 088/100] Removed workarounds from UrlValidator that were required for guzzle 6.5.0 --- module/Core/src/Util/UrlValidator.php | 29 ++----------- module/Core/test/Util/UrlValidatorTest.php | 49 +++------------------- 2 files changed, 9 insertions(+), 69 deletions(-) diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index c91f37ff..db7c6c2a 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -5,15 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Util; use Fig\Http\Message\RequestMethodInterface; -use Fig\Http\Message\StatusCodeInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; -use function Functional\contains; - -class UrlValidator implements UrlValidatorInterface, RequestMethodInterface, StatusCodeInterface +class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { private const MAX_REDIRECTS = 15; @@ -30,32 +27,12 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface, Sta */ public function validateUrl(string $url): void { - $this->doValidateUrl($url); - } - - /** - * @throws InvalidUrlException - */ - private function doValidateUrl(string $url, int $redirectNum = 1): void - { - // TODO Guzzle does not properly handle IDNs on redirects, just on first request. - // Because of that, we have to handle redirects manually. try { - $resp = $this->httpClient->request(self::METHOD_GET, $url, [ -// RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], - RequestOptions::ALLOW_REDIRECTS => false, + $this->httpClient->request(self::METHOD_GET, $url, [ + RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], ]); - - if ($redirectNum < self::MAX_REDIRECTS && $this->statusIsRedirect($resp->getStatusCode())) { - $this->doValidateUrl($resp->getHeaderLine('Location'), $redirectNum + 1); - } } catch (GuzzleException $e) { throw InvalidUrlException::fromUrl($url, $e); } } - - private function statusIsRedirect(int $statusCode): bool - { - return contains([self::STATUS_MOVED_PERMANENTLY, self::STATUS_FOUND], $statusCode); - } } diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index af558e44..3a6880ea 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -7,17 +7,14 @@ namespace ShlinkioTest\Shlink\Core\Util; use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\RequestOptions; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Util\UrlValidator; -use Zend\Diactoros\Request; use Zend\Diactoros\Response; -use function Functional\map; -use function range; - class UrlValidatorTest extends TestCase { /** @var UrlValidator */ @@ -31,39 +28,17 @@ class UrlValidatorTest extends TestCase $this->urlValidator = new UrlValidator($this->httpClient->reveal()); } - /** - * @test - * @dataProvider provideAttemptThatThrows - */ - public function exceptionIsThrownWhenUrlIsInvalid(int $attemptThatThrows): void + /** @test */ + public function exceptionIsThrownWhenUrlIsInvalid(): void { - $callNum = 1; - $e = new ClientException('', $this->prophesize(Request::class)->reveal()); + $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); - $request = $this->httpClient->request(Argument::cetera())->will( - function () use ($e, $attemptThatThrows, &$callNum) { - if ($callNum === $attemptThatThrows) { - throw $e; - } - - $callNum++; - return new Response('php://memory', 302, ['Location' => 'http://foo.com']); - } - ); - - $request->shouldBeCalledTimes($attemptThatThrows); + $request->shouldBeCalledOnce(); $this->expectException(InvalidUrlException::class); $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar'); } - public function provideAttemptThatThrows(): iterable - { - return map(range(1, 15), function (int $attempt) { - return [$attempt]; - }); - } - /** @test */ public function expectedUrlIsCalledWhenTryingToVerify(): void { @@ -72,23 +47,11 @@ class UrlValidatorTest extends TestCase $request = $this->httpClient->request( RequestMethodInterface::METHOD_GET, $expectedUrl, - Argument::cetera() + [RequestOptions::ALLOW_REDIRECTS => ['max' => 15]] )->willReturn(new Response()); $this->urlValidator->validateUrl($expectedUrl); $request->shouldHaveBeenCalledOnce(); } - - /** @test */ - public function urlIsConsideredValidWhenTooManyRedirectsAreReturned(): void - { - $request = $this->httpClient->request(Argument::cetera())->willReturn( - new Response('php://memory', 302, ['Location' => 'http://foo.com']) - ); - - $this->urlValidator->validateUrl('http://foobar.com'); - - $request->shouldHaveBeenCalledTimes(15); - } } From 3fdba53995f4e7700172f75db2c316fae87b372c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Dec 2019 09:25:07 +0100 Subject: [PATCH 089/100] Added basic implementation for new webhook events --- config/autoload/url-shortener.global.php | 1 + .../Core/config/event_dispatcher.config.php | 10 +++ .../EventDispatcher/NotifyVisitToWebHooks.php | 67 +++++++++++++++++++ .../src/EventDispatcher/ShortUrlLocated.php | 28 ++++++++ 4 files changed, 106 insertions(+) create mode 100644 module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php create mode 100644 module/Core/src/EventDispatcher/ShortUrlLocated.php diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 46e12593..58bc3faa 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -10,6 +10,7 @@ return [ 'hostname' => '', ], 'validate_url' => true, + 'visits_webhooks' => [], ], ]; diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index b5d86b09..8cee1f5c 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; +use GuzzleHttp\ClientInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; @@ -16,6 +17,9 @@ return [ EventDispatcher\ShortUrlVisited::class => [ EventDispatcher\LocateShortUrlVisit::class, ], + EventDispatcher\ShortUrlLocated::class => [ + EventDispatcher\NotifyVisitToWebHooks::class, + ], ], ], @@ -32,6 +36,12 @@ return [ 'Logger_Shlink', GeolocationDbUpdater::class, ], + EventDispatcher\NotifyVisitToWebHooks::class => [ + 'httpClient', + 'em', + 'Logger_Shlink', + 'config.url_shortener.visits_webhooks', + ], ], ]; diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php new file mode 100644 index 00000000..995323e1 --- /dev/null +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -0,0 +1,67 @@ +httpClient = $httpClient; + $this->em = $em; + $this->logger = $logger; + $this->webhooks = $webhooks; + } + + public function __invoke(ShortUrlLocated $shortUrlLocated): void + { + $visitId = $shortUrlLocated->visitId(); + + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + $requestOptions = [ + RequestOptions::TIMEOUT => 10, + RequestOptions::JSON => $visit->jsonSerialize(), + ]; + $requestPromises = map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) { + $promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions); + return $promise->otherwise(function () use ($webhook, $visitId) { + // Log failures + $this->logger->warning('Failed to notify visit with id "{visitId}" to "{webhook}" webhook', [ + 'visitId' => $visitId, + 'webhook' => $webhook, + ]); + }); + }); + } +} diff --git a/module/Core/src/EventDispatcher/ShortUrlLocated.php b/module/Core/src/EventDispatcher/ShortUrlLocated.php new file mode 100644 index 00000000..63390ebf --- /dev/null +++ b/module/Core/src/EventDispatcher/ShortUrlLocated.php @@ -0,0 +1,28 @@ +visitId = $visitId; + } + + public function visitId(): string + { + return $this->visitId; + } + + public function jsonSerialize(): array + { + return ['visitId' => $this->visitId]; + } +} From 25243a10ecb00a5f1e2a56a7cad529892fd69fc3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Dec 2019 14:02:43 +0100 Subject: [PATCH 090/100] Moved common bootstrapping code to run.php script --- bin/cli | 9 +++------ config/container.php | 15 ++++++++++----- config/run.php | 15 +++++++++++++++ module/Core/config/event_dispatcher.config.php | 1 - public/index.php | 10 ++-------- 5 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 config/run.php diff --git a/bin/cli b/bin/cli index 7f512eb0..c185efd3 100755 --- a/bin/cli +++ b/bin/cli @@ -1,10 +1,7 @@ #!/usr/bin/env php get(CliApp::class)->run(); +$run = require __DIR__ . '/../config/run.php'; +$run(true); diff --git a/config/container.php b/config/container.php index 29bc3e28..2ea9dc06 100644 --- a/config/container.php +++ b/config/container.php @@ -10,10 +10,15 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name -class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory'); +if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) { + class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory'); +} // Build container -$config = require __DIR__ . '/config.php'; -$container = new ServiceManager($config['dependencies']); -$container->setService('config', $config); -return $container; +return (function () { + $config = require __DIR__ . '/config.php'; + $container = new ServiceManager($config['dependencies']); + $container->setService('config', $config); + + return $container; +})(); diff --git a/config/run.php b/config/run.php new file mode 100644 index 00000000..4ea61775 --- /dev/null +++ b/config/run.php @@ -0,0 +1,15 @@ +get($isCli ? CliApp::class : Application::class); + + $app->run(); +}; diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 8cee1f5c..60869d04 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; -use GuzzleHttp\ClientInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; diff --git a/public/index.php b/public/index.php index 336f5cc2..78bb412a 100644 --- a/public/index.php +++ b/public/index.php @@ -2,11 +2,5 @@ declare(strict_types=1); -use Psr\Container\ContainerInterface; -use Zend\Expressive\Application; - -(function () { - /** @var ContainerInterface $container */ - $container = include __DIR__ . '/../config/container.php'; - $container->get(Application::class)->run(); -})(); +$run = require __DIR__ . '/../config/run.php'; +$run(); From 562b0a0868c70cd380da36fa67bfc78755c522a2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Dec 2019 16:15:14 +0100 Subject: [PATCH 091/100] Used PSR3 logger preprocessor format instead of sprintf when possible --- .../src/EventDispatcher/LocateShortUrlVisit.php | 15 +++++++-------- .../EventDispatcher/LocateShortUrlVisitTest.php | 14 ++++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php index 1facba39..3c40d0a1 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php @@ -46,7 +46,9 @@ class LocateShortUrlVisit /** @var Visit|null $visit */ $visit = $this->em->find(Visit::class, $visitId); if ($visit === null) { - $this->logger->warning(sprintf('Tried to locate visit with id "%s", but it does not exist.', $visitId)); + $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); return; } @@ -57,11 +59,8 @@ class LocateShortUrlVisit } catch (GeolocationDbUpdateFailedException $e) { if (! $e->olderDbExists()) { $this->logger->error( - sprintf( - 'GeoLite2 database download failed. It is not possible to locate visit with id %s. {e}', - $visitId - ), - ['e' => $e] + 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', + ['e' => $e, 'visitId' => $visitId] ); return; } @@ -75,8 +74,8 @@ class LocateShortUrlVisit : Location::emptyInstance(); } catch (WrongIpException $e) { $this->logger->warning( - sprintf('Tried to locate visit with id "%s", but its address seems to be wrong. {e}', $visitId), - ['e' => $e] + 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', + ['e' => $e, 'visitId' => $visitId] ); return; } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 0b557dd8..88b7d6c0 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -56,7 +56,9 @@ class LocateShortUrlVisitTest extends TestCase { $event = new ShortUrlVisited('123'); $findVisit = $this->em->find(Visit::class, '123')->willReturn(null); - $logWarning = $this->logger->warning('Tried to locate visit with id "123", but it does not exist.'); + $logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ + 'visitId' => 123, + ]); ($this->locateVisit)($event); @@ -77,7 +79,7 @@ class LocateShortUrlVisitTest extends TestCase WrongIpException::class ); $logWarning = $this->logger->warning( - Argument::containingString('Tried to locate visit with id "123", but its address seems to be wrong.'), + Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'), Argument::type('array') ); @@ -142,7 +144,7 @@ class LocateShortUrlVisitTest extends TestCase } /** @test */ - public function errorWhenUpdatingGeoliteWithExistingCopyLogsWarning(): void + public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void { $e = GeolocationDbUpdateFailedException::create(true); $ipAddr = '1.2.3.0'; @@ -170,7 +172,7 @@ class LocateShortUrlVisitTest extends TestCase } /** @test */ - public function errorWhenDownloadingGeoliteCancelsLocation(): void + public function errorWhenDownloadingGeoLiteCancelsLocation(): void { $e = GeolocationDbUpdateFailedException::create(false); $ipAddr = '1.2.3.0'; @@ -184,8 +186,8 @@ class LocateShortUrlVisitTest extends TestCase $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); $logError = $this->logger->error( - 'GeoLite2 database download failed. It is not possible to locate visit with id 123. {e}', - ['e' => $e] + 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', + ['e' => $e, 'visitId' => 123] ); ($this->locateVisit)($event); From 21a3d4b66bd476eb1a3dbec5495d0ecceb9b01c7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Dec 2019 17:07:20 +0100 Subject: [PATCH 092/100] Updated NotifyVisitToWebHooks so that it waits for all requests to finish --- .../Core/config/event_dispatcher.config.php | 1 + module/Core/src/Entity/Visit.php | 5 +++ .../EventDispatcher/NotifyVisitToWebHooks.php | 36 +++++++++++++------ .../Transformer/ShortUrlDataTransformer.php | 13 ++++--- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 60869d04..ae2ca64a 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -40,6 +40,7 @@ return [ 'em', 'Logger_Shlink', 'config.url_shortener.visits_webhooks', + 'config.url_shortener.domain', ], ], diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index ee1b5394..b2ce3640 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -61,6 +61,11 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } + public function getShortUrl(): ShortUrl + { + return $this->shortUrl; + } + public function getVisitLocation(): VisitLocationInterface { return $this->visitLocation ?? new UnknownVisitLocation(); diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 995323e1..fea69ccd 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -10,8 +10,12 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\RequestOptions; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; +use Throwable; use function Functional\map; +use function Functional\partial_left; +use function GuzzleHttp\Promise\settle; class NotifyVisitToWebHooks { @@ -23,17 +27,21 @@ class NotifyVisitToWebHooks private $logger; /** @var array */ private $webhooks; + /** @var ShortUrlDataTransformer */ + private $transformer; public function __construct( ClientInterface $httpClient, EntityManagerInterface $em, LoggerInterface $logger, - array $webhooks + array $webhooks, + array $domainConfig ) { $this->httpClient = $httpClient; $this->em = $em; $this->logger = $logger; $this->webhooks = $webhooks; + $this->transformer = new ShortUrlDataTransformer($domainConfig); } public function __invoke(ShortUrlLocated $shortUrlLocated): void @@ -51,17 +59,25 @@ class NotifyVisitToWebHooks $requestOptions = [ RequestOptions::TIMEOUT => 10, - RequestOptions::JSON => $visit->jsonSerialize(), + RequestOptions::JSON => [ + 'shortUrl' => $this->transformer->transform($visit->getShortUrl(), false), + 'visit' => $visit->jsonSerialize(), + ], ]; - $requestPromises = map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) { + $logWebhookWarning = function (string $webhook, Throwable $e) use ($visitId): void { + $this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [ + 'visitId' => $visitId, + 'webhook' => $webhook, + 'e' => $e, + ]); + }; + + $requestPromises = map($this->webhooks, function (string $webhook) use ($requestOptions, $logWebhookWarning) { $promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions); - return $promise->otherwise(function () use ($webhook, $visitId) { - // Log failures - $this->logger->warning('Failed to notify visit with id "{visitId}" to "{webhook}" webhook', [ - 'visitId' => $visitId, - 'webhook' => $webhook, - ]); - }); + return $promise->otherwise(partial_left($logWebhookWarning, $webhook)); }); + + // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error. + settle($requestPromises)->wait(); } } diff --git a/module/Core/src/Transformer/ShortUrlDataTransformer.php b/module/Core/src/Transformer/ShortUrlDataTransformer.php index 4562782f..348ff0d5 100644 --- a/module/Core/src/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/Transformer/ShortUrlDataTransformer.php @@ -23,11 +23,11 @@ class ShortUrlDataTransformer implements DataTransformerInterface /** * @param ShortUrl $shortUrl */ - public function transform($shortUrl): array + public function transform($shortUrl, bool $includeDeprecated = true): array { $longUrl = $shortUrl->getLongUrl(); - return [ + $rawData = [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $shortUrl->toString($this->domainConfig), 'longUrl' => $longUrl, @@ -35,10 +35,13 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'visitsCount' => $shortUrl->getVisitsCount(), 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), - - // Deprecated - 'originalUrl' => $longUrl, ]; + + if ($includeDeprecated) { + $rawData['originalUrl'] = $longUrl; + } + + return $rawData; } private function buildMeta(ShortUrl $shortUrl): array From 79cd3ba91299b33637605a9fc416163190e851a1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Dec 2019 20:12:13 +0100 Subject: [PATCH 093/100] Created NotifyVisitToWebhooksTest --- .../EventDispatcher/NotifyVisitToWebHooks.php | 56 +++++++-- .../NotifyVisitToWebHooksTest.php | 114 ++++++++++++++++++ 2 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index fea69ccd..21fd4c1f 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher; +use Closure; use Doctrine\ORM\EntityManagerInterface; use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Promise\Promise; use GuzzleHttp\RequestOptions; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Throwable; @@ -29,23 +32,31 @@ class NotifyVisitToWebHooks private $webhooks; /** @var ShortUrlDataTransformer */ private $transformer; + /** @var AppOptions */ + private $appOptions; public function __construct( ClientInterface $httpClient, EntityManagerInterface $em, LoggerInterface $logger, array $webhooks, - array $domainConfig + array $domainConfig, + AppOptions $appOptions ) { $this->httpClient = $httpClient; $this->em = $em; $this->logger = $logger; $this->webhooks = $webhooks; $this->transformer = new ShortUrlDataTransformer($domainConfig); + $this->appOptions = $appOptions; } public function __invoke(ShortUrlLocated $shortUrlLocated): void { + if (empty($this->webhooks)) { + return; + } + $visitId = $shortUrlLocated->visitId(); /** @var Visit|null $visit */ @@ -57,27 +68,46 @@ class NotifyVisitToWebHooks return; } - $requestOptions = [ + $requestOptions = $this->buildRequestOptions($visit); + $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(); + } + + private function buildRequestOptions(Visit $visit): array + { + return [ RequestOptions::TIMEOUT => 10, + RequestOptions::HEADERS => [ + 'User-Agent' => (string) $this->appOptions, + ], RequestOptions::JSON => [ 'shortUrl' => $this->transformer->transform($visit->getShortUrl(), false), 'visit' => $visit->jsonSerialize(), ], ]; - $logWebhookWarning = function (string $webhook, Throwable $e) use ($visitId): void { - $this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [ - 'visitId' => $visitId, - 'webhook' => $webhook, - 'e' => $e, - ]); - }; + } - $requestPromises = map($this->webhooks, function (string $webhook) use ($requestOptions, $logWebhookWarning) { + /** + * @param Promise[] $requestOptions + */ + private function performRequests(array $requestOptions, string $visitId): array + { + return map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) { $promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions); - return $promise->otherwise(partial_left($logWebhookWarning, $webhook)); + return $promise->otherwise( + partial_left(Closure::fromCallable([$this, 'logWebhookFailure']), $webhook, $visitId) + ); }); + } - // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error. - settle($requestPromises)->wait(); + private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void + { + $this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [ + 'visitId' => $visitId, + 'webhook' => $webhook, + 'e' => $e, + ]); } } diff --git a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php new file mode 100644 index 00000000..33737d01 --- /dev/null +++ b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -0,0 +1,114 @@ +httpClient = $this->prophesize(ClientInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** @test */ + public function emptyWebhooksMakeNoFurtherActions(): void + { + $find = $this->em->find(Visit::class, '1')->willReturn(null); + + $this->createListener([])(new ShortUrlLocated('1')); + + $find->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function invalidVisitDoesNotPerformAnyRequest(): void + { + $find = $this->em->find(Visit::class, '1')->willReturn(null); + $requestAsync = $this->httpClient->requestAsync( + RequestMethodInterface::METHOD_POST, + Argument::type('string'), + Argument::type('array') + )->willReturn(new FulfilledPromise('')); + $logWarning = $this->logger->warning( + 'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', + ['visitId' => '1'] + ); + + $this->createListener(['foo', 'bar'])(new ShortUrlLocated('1')); + + $find->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $requestAsync->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedRequestsArePerformedToWebhooks(): void + { + $webhooks = ['foo', 'invalid', 'bar', 'baz']; + $invalidWebhooks = ['invalid', 'baz']; + + $find = $this->em->find(Visit::class, '1')->willReturn(new Visit(new ShortUrl(''), Visitor::emptyInstance())); + $requestAsync = $this->httpClient->requestAsync( + RequestMethodInterface::METHOD_POST, + Argument::type('string'), + Argument::type('array') + )->will(function (array $args) use ($invalidWebhooks) { + [, $webhook] = $args; + $e = new Exception(''); + + return contains($invalidWebhooks, $webhook) ? new RejectedPromise($e) : new FulfilledPromise(''); + }); + $logWarning = $this->logger->warning( + 'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', + Argument::type('array') + ); + + $this->createListener($webhooks)(new ShortUrlLocated('1')); + + $find->shouldHaveBeenCalledOnce(); + $requestAsync->shouldHaveBeenCalledTimes(count($webhooks)); + $logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks)); + } + + private function createListener(array $webhooks): NotifyVisitToWebHooks + { + return new NotifyVisitToWebHooks( + $this->httpClient->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $webhooks, + [], + new AppOptions() + ); + } +} From 4886825564411f92a258bd0c60372ef98c77b975 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Dec 2019 10:43:13 +0100 Subject: [PATCH 094/100] Improved NotifyVisitToWebHooksTest to kill more mutants --- .../NotifyVisitToWebHooksTest.php | 24 ++++++++++++++++--- phpunit-api.xml | 2 +- phpunit-db.xml | 2 +- phpunit.xml.dist | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 33737d01..bb594e7d 100644 --- a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -10,6 +10,8 @@ use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\RequestOptions; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; @@ -81,7 +83,17 @@ class NotifyVisitToWebHooksTest extends TestCase $requestAsync = $this->httpClient->requestAsync( RequestMethodInterface::METHOD_POST, Argument::type('string'), - Argument::type('array') + Argument::that(function (array $requestOptions) { + Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions); + Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions); + Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions); + Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10); + Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']); + Assert::assertArrayHasKey('shortUrl', $requestOptions[RequestOptions::JSON]); + Assert::assertArrayHasKey('visit', $requestOptions[RequestOptions::JSON]); + + return $requestOptions; + }) )->will(function (array $args) use ($invalidWebhooks) { [, $webhook] = $args; $e = new Exception(''); @@ -90,7 +102,13 @@ class NotifyVisitToWebHooksTest extends TestCase }); $logWarning = $this->logger->warning( 'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', - Argument::type('array') + Argument::that(function (array $extra) { + Assert::assertArrayHasKey('webhook', $extra); + Assert::assertArrayHasKey('visitId', $extra); + Assert::assertArrayHasKey('e', $extra); + + return $extra; + }) ); $this->createListener($webhooks)(new ShortUrlLocated('1')); @@ -108,7 +126,7 @@ class NotifyVisitToWebHooksTest extends TestCase $this->logger->reveal(), $webhooks, [], - new AppOptions() + new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']) ); } } diff --git a/phpunit-api.xml b/phpunit-api.xml index 69132097..6e481fe5 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -1,7 +1,7 @@ diff --git a/phpunit-db.xml b/phpunit-db.xml index eab4be28..86cdbbc6 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -1,7 +1,7 @@ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1ae25124..67d08507 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ From b17bcb6c936891c1e8306193a543731abaef3820 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Dec 2019 13:07:11 +0100 Subject: [PATCH 095/100] Updated LocateShortUrlVisit so that it dispatches a VisitLocated event --- .../Core/config/event_dispatcher.config.php | 4 ++- .../EventDispatcher/LocateShortUrlVisit.php | 30 +++++++++++++++---- .../EventDispatcher/NotifyVisitToWebHooks.php | 2 +- .../{ShortUrlLocated.php => VisitLocated.php} | 2 +- .../LocateShortUrlVisitTest.php | 26 +++++++++++++++- .../NotifyVisitToWebHooksTest.php | 8 ++--- 6 files changed, 58 insertions(+), 14 deletions(-) rename module/Core/src/EventDispatcher/{ShortUrlLocated.php => VisitLocated.php} (88%) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index ae2ca64a..996669c3 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; @@ -16,7 +17,7 @@ return [ EventDispatcher\ShortUrlVisited::class => [ EventDispatcher\LocateShortUrlVisit::class, ], - EventDispatcher\ShortUrlLocated::class => [ + EventDispatcher\VisitLocated::class => [ EventDispatcher\NotifyVisitToWebHooks::class, ], ], @@ -34,6 +35,7 @@ return [ 'em', 'Logger_Shlink', GeolocationDbUpdater::class, + EventDispatcherInterface::class, ], EventDispatcher\NotifyVisitToWebHooks::class => [ 'httpClient', diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php index 3c40d0a1..4d767272 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; @@ -26,17 +27,21 @@ class LocateShortUrlVisit private $logger; /** @var GeolocationDbUpdaterInterface */ private $dbUpdater; + /** @var EventDispatcherInterface */ + private $eventDispatcher; public function __construct( IpLocationResolverInterface $ipLocationResolver, EntityManagerInterface $em, LoggerInterface $logger, - GeolocationDbUpdaterInterface $dbUpdater + GeolocationDbUpdaterInterface $dbUpdater, + EventDispatcherInterface $eventDispatcher ) { $this->ipLocationResolver = $ipLocationResolver; $this->em = $em; $this->logger = $logger; $this->dbUpdater = $dbUpdater; + $this->eventDispatcher = $eventDispatcher; } public function __invoke(ShortUrlVisited $shortUrlVisited): void @@ -52,6 +57,15 @@ class LocateShortUrlVisit return; } + if ($this->downloadOrUpdateGeoLiteDb($visitId)) { + $this->locateVisit($visitId, $visit); + } + + $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + } + + private function downloadOrUpdateGeoLiteDb(string $visitId): bool + { try { $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) { $this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading')); @@ -62,25 +76,29 @@ class LocateShortUrlVisit 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', ['e' => $e, 'visitId' => $visitId] ); - return; + return false; } $this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]); } + return true; + } + + private function locateVisit(string $visitId, Visit $visit): void + { try { $location = $visit->isLocatable() ? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr()) : Location::emptyInstance(); + + $visit->locate(new VisitLocation($location)); + $this->em->flush(); } catch (WrongIpException $e) { $this->logger->warning( 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', ['e' => $e, 'visitId' => $visitId] ); - return; } - - $visit->locate(new VisitLocation($location)); - $this->em->flush(); } } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 21fd4c1f..d99defb5 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -51,7 +51,7 @@ class NotifyVisitToWebHooks $this->appOptions = $appOptions; } - public function __invoke(ShortUrlLocated $shortUrlLocated): void + public function __invoke(VisitLocated $shortUrlLocated): void { if (empty($this->webhooks)) { return; diff --git a/module/Core/src/EventDispatcher/ShortUrlLocated.php b/module/Core/src/EventDispatcher/VisitLocated.php similarity index 88% rename from module/Core/src/EventDispatcher/ShortUrlLocated.php rename to module/Core/src/EventDispatcher/VisitLocated.php index 63390ebf..4873ffa7 100644 --- a/module/Core/src/EventDispatcher/ShortUrlLocated.php +++ b/module/Core/src/EventDispatcher/VisitLocated.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher; use JsonSerializable; -final class ShortUrlLocated implements JsonSerializable +final class VisitLocated implements JsonSerializable { /** @var string */ private $visitId; diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 88b7d6c0..a451ddea 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; @@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; @@ -35,6 +37,8 @@ class LocateShortUrlVisitTest extends TestCase private $logger; /** @var ObjectProphecy */ private $dbUpdater; + /** @var ObjectProphecy */ + private $eventDispatcher; public function setUp(): void { @@ -42,12 +46,14 @@ class LocateShortUrlVisitTest extends TestCase $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $this->locateVisit = new LocateShortUrlVisit( $this->ipLocationResolver->reveal(), $this->em->reveal(), $this->logger->reveal(), - $this->dbUpdater->reveal() + $this->dbUpdater->reveal(), + $this->eventDispatcher->reveal() ); } @@ -59,6 +65,8 @@ class LocateShortUrlVisitTest extends TestCase $logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ 'visitId' => 123, ]); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -66,6 +74,7 @@ class LocateShortUrlVisitTest extends TestCase $this->em->flush()->shouldNotHaveBeenCalled(); $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled(); $logWarning->shouldHaveBeenCalled(); + $dispatch->shouldNotHaveBeenCalled(); } /** @test */ @@ -82,6 +91,8 @@ class LocateShortUrlVisitTest extends TestCase Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'), Argument::type('array') ); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -89,6 +100,7 @@ class LocateShortUrlVisitTest extends TestCase $resolveLocation->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalled(); $this->em->flush()->shouldNotHaveBeenCalled(); + $dispatch->shouldHaveBeenCalledOnce(); } /** @@ -102,6 +114,8 @@ class LocateShortUrlVisitTest extends TestCase $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any()); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -110,6 +124,7 @@ class LocateShortUrlVisitTest extends TestCase $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldNotHaveBeenCalled(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $dispatch->shouldHaveBeenCalledOnce(); } public function provideNonLocatableVisits(): iterable @@ -133,6 +148,8 @@ class LocateShortUrlVisitTest extends TestCase $flush = $this->em->flush()->will(function () { }); $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -141,6 +158,7 @@ class LocateShortUrlVisitTest extends TestCase $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $dispatch->shouldHaveBeenCalledOnce(); } /** @test */ @@ -157,6 +175,8 @@ class LocateShortUrlVisitTest extends TestCase }); $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -169,6 +189,7 @@ class LocateShortUrlVisitTest extends TestCase 'GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e] )->shouldHaveBeenCalledOnce(); + $dispatch->shouldHaveBeenCalledOnce(); } /** @test */ @@ -189,6 +210,8 @@ class LocateShortUrlVisitTest extends TestCase 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', ['e' => $e, 'visitId' => 123] ); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () { + }); ($this->locateVisit)($event); @@ -198,5 +221,6 @@ class LocateShortUrlVisitTest extends TestCase $resolveIp->shouldNotHaveBeenCalled(); $checkUpdateDb->shouldHaveBeenCalledOnce(); $logError->shouldHaveBeenCalledOnce(); + $dispatch->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php index bb594e7d..305f2e23 100644 --- a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -19,7 +19,7 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; -use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlLocated; +use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; @@ -47,7 +47,7 @@ class NotifyVisitToWebHooksTest extends TestCase { $find = $this->em->find(Visit::class, '1')->willReturn(null); - $this->createListener([])(new ShortUrlLocated('1')); + $this->createListener([])(new VisitLocated('1')); $find->shouldNotHaveBeenCalled(); } @@ -66,7 +66,7 @@ class NotifyVisitToWebHooksTest extends TestCase ['visitId' => '1'] ); - $this->createListener(['foo', 'bar'])(new ShortUrlLocated('1')); + $this->createListener(['foo', 'bar'])(new VisitLocated('1')); $find->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalledOnce(); @@ -111,7 +111,7 @@ class NotifyVisitToWebHooksTest extends TestCase }) ); - $this->createListener($webhooks)(new ShortUrlLocated('1')); + $this->createListener($webhooks)(new VisitLocated('1')); $find->shouldHaveBeenCalledOnce(); $requestAsync->shouldHaveBeenCalledTimes(count($webhooks)); From 583985e7cedefd3e44b8e88b768d46df35d865bd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Dec 2019 13:50:41 +0100 Subject: [PATCH 096/100] Moved VisitLocated as a regular event, since async tasks cannot trigger other async tasks --- module/Core/config/event_dispatcher.config.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 996669c3..aead1447 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -12,20 +12,22 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; return [ 'events' => [ - 'regular' => [], + 'regular' => [ + EventDispatcher\VisitLocated::class => [ + EventDispatcher\NotifyVisitToWebHooks::class, + ], + ], 'async' => [ EventDispatcher\ShortUrlVisited::class => [ EventDispatcher\LocateShortUrlVisit::class, ], - EventDispatcher\VisitLocated::class => [ - EventDispatcher\NotifyVisitToWebHooks::class, - ], ], ], 'dependencies' => [ 'factories' => [ EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, ], ], @@ -43,6 +45,7 @@ return [ 'Logger_Shlink', 'config.url_shortener.visits_webhooks', 'config.url_shortener.domain', + Options\AppOptions::class, ], ], From 3c9da809627a43ea185fd2ed00357ca2691a1d96 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Dec 2019 16:35:16 +0100 Subject: [PATCH 097/100] Documented how to provide visits webhooks to docker image via env vars --- docker/README.md | 6 ++++++ docker/config/shlink_in_docker.local.php | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/docker/README.md b/docker/README.md index 66e8e743..b600c268 100644 --- a/docker/README.md +++ b/docker/README.md @@ -110,6 +110,7 @@ This is the complete list of supported env vars: * `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`. * `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16. * `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16. +* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit. * `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel). This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately. @@ -145,6 +146,7 @@ docker run \ -e "BASE_PATH=/my-campaign" \ -e WEB_WORKER_NUM=64 \ -e TASK_WORKER_NUM=32 \ + -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ shlinkio/shlink:stable ``` @@ -173,6 +175,10 @@ The whole configuration should have this format, but it can be split into multip "tcp://172.20.0.1:6379", "tcp://172.20.0.2:6379" ], + "visits_webhooks": [ + "http://my-api.com/api/v2.3/notify", + "https://third-party.io/foo" + ], "db_config": { "driver": "pdo_mysql", "dbname": "shlink", diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 5da6761d..6d3367ac 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -99,6 +99,12 @@ $helper = new class { 'base_url' => env('BASE_URL_REDIRECT_TO'), ]; } + + public function getVisitsWebhooks(): array + { + $webhooks = env('VISITS_WEBHOOKS'); + return $webhooks === null ? [] : explode(',', $webhooks); + } }; return [ @@ -125,6 +131,7 @@ return [ 'hostname' => env('SHORT_DOMAIN_HOST', ''), ], 'validate_url' => (bool) env('VALIDATE_URLS', true), + 'visits_webhooks' => $helper->getVisitsWebhooks(), ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), From 664569a52b92b60b48cbb1d0885c987220eff82a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 28 Dec 2019 16:42:21 +0100 Subject: [PATCH 098/100] Added visits_webhooks option to SimplifiedConfigParser --- module/Core/src/Config/SimplifiedConfigParser.php | 1 + module/Core/test/Config/SimplifiedConfigParserTest.php | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index 95c777e8..5ee912b0 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -32,6 +32,7 @@ class SimplifiedConfigParser 'base_path' => ['router', 'base_path'], 'web_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'worker_num'], 'task_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'task_worker_num'], + 'visits_webhooks' => ['url_shortener', 'visits_webhooks'], ]; private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ 'delete_short_url_threshold' => [ diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 20afab7f..76ba9b1b 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -53,6 +53,10 @@ class SimplifiedConfigParserTest extends TestCase ], 'base_path' => '/foo/bar', 'task_worker_num' => 50, + 'visits_webhooks' => [ + 'http://my-api.com/api/v2.3/notify', + 'https://third-party.io/foo', + ], ]; $expected = [ 'app_options' => [ @@ -76,6 +80,10 @@ class SimplifiedConfigParserTest extends TestCase 'hostname' => 'doma.in', ], 'validate_url' => false, + 'visits_webhooks' => [ + 'http://my-api.com/api/v2.3/notify', + 'https://third-party.io/foo', + ], ], 'delete_short_urls' => [ From 8667544b3a1a7187f0a790305ce57ab96313e6e3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 29 Dec 2019 14:09:51 +0100 Subject: [PATCH 099/100] Updated to installer v3.3 --- composer.json | 10 +++++----- config/autoload/installer.global.php | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 32105e23..7782f61d 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "pugx/shortid-php": "^0.5", "shlinkio/shlink-common": "^2.4", "shlinkio/shlink-event-dispatcher": "^1.1", - "shlinkio/shlink-installer": "^3.2", + "shlinkio/shlink-installer": "^3.3", "shlinkio/shlink-ip-geolocation": "^1.2", "symfony/console": "^5.0", "symfony/filesystem": "^5.0", @@ -111,7 +111,7 @@ "@test:api" ], "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", - "test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml --testdox", + "test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml", "test:db": [ "@test:db:sqlite", "@test:db:mysql", @@ -123,15 +123,15 @@ "@test:db:mysql", "@test:db:postgres" ], - "test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox", + "test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", "test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage", "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered", - "infect:ci": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --coverage=build", - "infect:show": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --show-mutations", + "infect:ci": "@infect --coverage=build", + "infect:show": "@infect --show-mutations", "infect:test": [ "@test:unit:ci", "@infect:ci" diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 91ebdab7..402e6bb3 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -11,6 +11,8 @@ return [ Plugin\UrlShortenerConfigCustomizer::SCHEMA, Plugin\UrlShortenerConfigCustomizer::HOSTNAME, Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL, + Plugin\UrlShortenerConfigCustomizer::NOTIFY_VISITS_WEBHOOKS, + Plugin\UrlShortenerConfigCustomizer::VISITS_WEBHOOKS, ], Plugin\ApplicationConfigCustomizer::class => [ From b4e3dd7b4e74990acc0b421c51f17c9e43606b78 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 29 Dec 2019 14:15:50 +0100 Subject: [PATCH 100/100] Updated changelog with v1.21.0 --- CHANGELOG.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fe8298..bb001cb4 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] +## 1.21.0 - 2019-12-29 #### Added @@ -22,6 +22,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params. * The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided. +* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole. + + Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit. + + The payload will look like this: + + ```json + { + "shortUrl": {}, + "visit": {} + } + ``` + + > The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io). + #### Changed * [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php.