From 76d6d9a7a9b6467ca3034641c2f4c1e90bcec025 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 09:53:12 +0200 Subject: [PATCH 1/7] Created rest endpoint to list existing domains --- docs/swagger/paths/v2_domains.json | 86 +++++++++++++++++++ docs/swagger/swagger.json | 4 + module/Core/config/dependencies.config.php | 2 + .../Shlinkio.Shlink.Core.Entity.Domain.php | 3 +- module/Core/src/Domain/DomainService.php | 29 +++++++ .../src/Domain/DomainServiceInterface.php | 15 ++++ .../Domain/Repository/DomainRepository.php | 26 ++++++ .../Repository/DomainRepositoryInterface.php | 16 ++++ module/Rest/config/dependencies.config.php | 3 + module/Rest/config/routes.config.php | 5 +- .../src/Action/Domain/ListDomainsAction.php | 52 +++++++++++ 11 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 docs/swagger/paths/v2_domains.json create mode 100644 module/Core/src/Domain/DomainService.php create mode 100644 module/Core/src/Domain/DomainServiceInterface.php create mode 100644 module/Core/src/Domain/Repository/DomainRepository.php create mode 100644 module/Core/src/Domain/Repository/DomainRepositoryInterface.php create mode 100644 module/Rest/src/Action/Domain/ListDomainsAction.php diff --git a/docs/swagger/paths/v2_domains.json b/docs/swagger/paths/v2_domains.json new file mode 100644 index 00000000..d92ae995 --- /dev/null +++ b/docs/swagger/paths/v2_domains.json @@ -0,0 +1,86 @@ +{ + "get": { + "operationId": "listDomains", + "tags": [ + "Domains" + ], + "summary": "List existing domains", + "description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain", + "security": [ + { + "ApiKey": [] + } + ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], + "responses": { + "200": { + "description": "The list of tags", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["domains"], + "properties": { + "domains": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["domain", "isDefault"], + "properties": { + "domain": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "examples": { + "application/json": { + "domains": { + "data": [ + { + "domain": "example.com", + "isDefault": true + }, + { + "domain": "aaa.com", + "isDefault": false + }, + { + "domain": "bbb.com", + "isDefault": false + } + ] + } + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8dc21997..5abe1946 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -88,6 +88,10 @@ "$ref": "paths/v2_tags_{tag}_visits.json" }, + "/rest/v{version}/domains": { + "$ref": "paths/v2_domains.json" + }, + "/rest/v{version}/mercure-info": { "$ref": "paths/v2_mercure-info.json" }, diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 46bf1735..5dcef9a2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -31,6 +31,7 @@ return [ Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, + Domain\DomainService::class => ConfigAbstractFactory::class, Util\UrlValidator::class => ConfigAbstractFactory::class, @@ -69,6 +70,7 @@ return [ Service\ShortUrl\ShortUrlResolver::class, ], Service\ShortUrl\ShortUrlResolver::class => ['em'], + Domain\DomainService::class => ['em'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index c6349b74..e3d8c3cf 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -11,7 +11,8 @@ use Doctrine\ORM\Mapping\ClassMetadata; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); - $builder->setTable(determineTableName('domains', $emConfig)); + $builder->setTable(determineTableName('domains', $emConfig)) + ->setCustomRepositoryClass(Domain\Repository\DomainRepository::class); $builder->createField('id', Types::BIGINT) ->columnName('id') diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php new file mode 100644 index 00000000..d7575361 --- /dev/null +++ b/module/Core/src/Domain/DomainService.php @@ -0,0 +1,29 @@ +em = $em; + } + + /** + * @return Domain[] + */ + public function listDomainsWithout(?string $excludeDomain = null): array + { + /** @var DomainRepositoryInterface $repo */ + $repo = $this->em->getRepository(Domain::class); + return $repo->findDomainsWithout($excludeDomain); + } +} diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php new file mode 100644 index 00000000..3e56c69c --- /dev/null +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -0,0 +1,15 @@ +createQueryBuilder('d')->orderBy('d.authority', 'ASC'); + + if ($excludedAuthority !== null) { + $qb->where($qb->expr()->neq('d.authority', ':excludedAuthority')) + ->setParameter('excludedAuthority', $excludedAuthority); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php new file mode 100644 index 00000000..56a765ac --- /dev/null +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -0,0 +1,16 @@ + ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, + Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -72,6 +74,7 @@ return [ Action\Tag\DeleteTagsAction::class => [TagService::class], Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], + Action\Domain\ListDomainsAction::class => [DomainService::class, 'config.url_shortener.domain.hostname'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 0bde3da0..64333254 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -12,7 +12,7 @@ return [ 'routes' => [ Action\HealthAction::getRouteDef(), - // Short codes + // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ $contentNegotiationMiddleware, $dropDomainMiddleware, @@ -36,6 +36,9 @@ return [ Action\Tag\CreateTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\MercureInfoAction::getRouteDef(), ], diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php new file mode 100644 index 00000000..682286a1 --- /dev/null +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -0,0 +1,52 @@ +domainService = $domainService; + $this->defaultDomain = $defaultDomain; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + + return new JsonResponse([ + 'domains' => [ + 'data' => [ + $this->mapDomain($this->defaultDomain, true), + ...map($regularDomains, fn (Domain $domain) => $this->mapDomain($domain->getAuthority())), + ], + ], + ]); + } + + private function mapDomain(string $domain, bool $isDefault = false): array + { + return [ + 'domain' => $domain, + 'isDefault' => $isDefault, + ]; + } +} From 24aab5cc0e8e8cd2ae45cd049d69b36b2f80bea6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 10:11:41 +0200 Subject: [PATCH 2/7] Created unit tests for new Domain-related elements --- module/Core/test/Domain/DomainServiceTest.php | 49 ++++++++++++++++ .../Action/Domain/ListDomainsActionTest.php | 58 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 module/Core/test/Domain/DomainServiceTest.php create mode 100644 module/Rest/test/Action/Domain/ListDomainsActionTest.php diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php new file mode 100644 index 00000000..e91c3281 --- /dev/null +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -0,0 +1,49 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->domainService = new DomainService($this->em->reveal()); + } + + /** + * @test + * @dataProvider provideExcludedDomains + */ + public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void + { + $repo = $this->prophesize(DomainRepositoryInterface::class); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult); + + $result = $this->domainService->listDomainsWithout($excludedDomain); + + self::assertEquals($expectedResult, $result); + $getRepo->shouldHaveBeenCalledOnce(); + $findDomains->shouldHaveBeenCalledOnce(); + } + + public function provideExcludedDomains(): iterable + { + yield 'no excluded domain' => [null, []]; + yield 'foo.com excluded domain' => ['foo.com', []]; + yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]]; + yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]]; + } +} diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php new file mode 100644 index 00000000..fe8ffa07 --- /dev/null +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -0,0 +1,58 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->action = new ListDomainsAction($this->domainService->reveal(), 'foo.com'); + } + + /** @test */ + public function domainsAreProperlyListed(): void + { + $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ + new Domain('bar.com'), + new Domain('baz.com'), + ]); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $payload = $resp->getPayload(); + + self::assertEquals([ + 'domains' => [ + 'data' => [ + [ + 'domain' => 'foo.com', + 'isDefault' => true, + ], + [ + 'domain' => 'bar.com', + 'isDefault' => false, + ], + [ + 'domain' => 'baz.com', + 'isDefault' => false, + ], + ], + ], + ], $payload); + $listDomains->shouldHaveBeenCalledOnce(); + } +} From 614e1c37f82eb84123a646392ceb457d4192332a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 10:18:49 +0200 Subject: [PATCH 3/7] Added database test for Domainrepository --- .../Repository/DomainRepositoryTest.php | 39 +++++++++++++++++++ phpunit.xml.dist | 2 + 2 files changed, 41 insertions(+) create mode 100644 module/Core/test-db/Domain/Repository/DomainRepositoryTest.php diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php new file mode 100644 index 00000000..b79f15f1 --- /dev/null +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -0,0 +1,39 @@ +repo = $this->getEntityManager()->getRepository(Domain::class); + } + + /** @test */ + public function findDomainsReturnsExpectedResult(): void + { + $fooDomain = new Domain('foo.com'); + $barDomain = new Domain('bar.com'); + $bazDomain = new Domain('baz.com'); + + $this->getEntityManager()->persist($fooDomain); + $this->getEntityManager()->persist($barDomain); + $this->getEntityManager()->persist($bazDomain); + $this->getEntityManager()->flush(); + + self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout()); + self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); + self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); + self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 03f73521..c45743cf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,6 +23,8 @@ ./module/Core/src/Repository + ./module/Core/src/**/Repository + ./module/Core/src/**/**/Repository From 06eda073bf2594c8c99ee8b5e3451a291e03c09c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 10:23:17 +0200 Subject: [PATCH 4/7] Added API test for /domains endpoint --- .../Rest/test-api/Action/ListDomainsTest.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 module/Rest/test-api/Action/ListDomainsTest.php diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php new file mode 100644 index 00000000..045197e8 --- /dev/null +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -0,0 +1,37 @@ +callApiWithKey(self::METHOD_GET, '/domains'); + $respPayload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertEquals([ + 'domains' => [ + 'data' => [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + [ + 'domain' => 'some-domain.com', + 'isDefault' => false, + ], + ], + ], + ], $respPayload); + } +} From 073e4eeac803fba512806a75aa21a42d4494eb78 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 12:39:02 +0200 Subject: [PATCH 5/7] Created command to list domains --- module/CLI/config/cli.config.php | 2 + module/CLI/config/dependencies.config.php | 5 ++ .../src/Command/Domain/ListDomainsCommand.php | 49 +++++++++++++++++++ .../src/Action/Domain/ListDomainsAction.php | 1 - 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 module/CLI/src/Command/Domain/ListDomainsCommand.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index fa9efc69..6e32428a 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -25,6 +25,8 @@ return [ Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, + Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class, + Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 516bbbd4..199d29ef 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; +use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; @@ -52,6 +53,8 @@ return [ Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class, + + Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, ], ], @@ -84,6 +87,8 @@ return [ Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], + Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, SymfonyCli\Helper\ProcessHelper::class, diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php new file mode 100644 index 00000000..0368f1dd --- /dev/null +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -0,0 +1,49 @@ +domainService = $domainService; + $this->defaultDomain = $defaultDomain; + } + + protected function configure(): void + { + $this + ->setName(self::NAME) + ->setDescription('List all domains that have been ever used for some short URL'); + } + + protected function execute(InputInterface $input, OutputInterface $output): ?int + { + $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + + ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [ + [$this->defaultDomain, 'Yes'], + ...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']), + ]); + + return ExitCodes::EXIT_SUCCESS; + } +} diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 682286a1..7362123a 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -11,7 +11,6 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use function Functional\compose; use function Functional\map; class ListDomainsAction extends AbstractRestAction From 63a24342e31e70b8843b474557b9009ae22fd9bb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 12:48:24 +0200 Subject: [PATCH 6/7] Created unit test for ListDomainsCommand --- .../Command/Domain/ListDomainsCommandTest.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 module/CLI/test/Command/Domain/ListDomainsCommandTest.php diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php new file mode 100644 index 00000000..ded8572f --- /dev/null +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -0,0 +1,56 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + + $command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com'); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** @test */ + public function allDomainsAreProperlyPrinted(): void + { + $expectedOutput = <<domainService->listDomainsWithout('foo.com')->willReturn([ + new Domain('bar.com'), + new Domain('baz.com'), + ]); + + $this->commandTester->execute([]); + + self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + $listDomains->shouldHaveBeenCalledOnce(); + } +} From 34c10c0bc97dda4046ff7faad7b668e73ff77463 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 27 Sep 2020 12:50:03 +0200 Subject: [PATCH 7/7] Updated changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35260abc..ba16ae5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * On the `POST /short-url` and `PATCH /short-url/{shortCode}` endpoints, you can now pass `validateUrl: true/false` in order to enforce enabling or disabling validation, ignoring the global config. If the value is not provided, the global config is still normally applied. * On the `short-url:generate` CLI command, you can pass `--validate-url` or `--no-validate-url` flags, in order to enforce enabling or disabling validation. If none of them is provided, the global config is still normally applied. +* [#838](https://github.com/shlinkio/shlink/issues/838) Added new endpoint and CLI command to list existing domains. + + It returns both default domain and specific domains that were used for some short URLs. + + * REST endpoint: `GET /rest/v2/domains` + * CLI Command: `domain:list` + #### Changed * [#836](https://github.com/shlinkio/shlink/issues/836) Added support for the `-` notation while determining how to order the short URLs list, as in `?orderBy=shortCode-DESC`. This effectively deprecates the array notation (`?orderBy[shortCode]=DESC`), that will be removed in Shlink 3.0.0