diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 9d2c423f..987c967e 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -6,7 +6,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use const Shlinkio\Shlink\MIN_TASK_WORKERS; -return (static function () { +return (static function (): array { $taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16); return [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 25de914a..58c12f05 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -16,7 +16,7 @@ return (static function (): array { return [ 'url_shortener' => [ - 'domain' => [ + 'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http', 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), ], diff --git a/config/cli-app.php b/config/cli-app.php index a2272852..9287cbaf 100644 --- a/config/cli-app.php +++ b/config/cli-app.php @@ -5,7 +5,7 @@ declare(strict_types=1); use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application as CliApp; -return (static function () { +return (static function (): CliApp { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(CliApp::class); diff --git a/config/container.php b/config/container.php index 568eb1ee..074502cd 100644 --- a/config/container.php +++ b/config/container.php @@ -22,7 +22,7 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) { } // Build container -return (static function () { +return (static function (): ServiceManager { $config = require __DIR__ . '/config.php'; $container = new ServiceManager($config['dependencies']); $container->setService('config', $config); diff --git a/config/entity-manager.php b/config/entity-manager.php index 2b4794f7..6721fec3 100644 --- a/config/entity-manager.php +++ b/config/entity-manager.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Psr\Container\ContainerInterface; -return (static function () { +return (static function (): EntityManagerInterface { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(EntityManager::class); diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index ed29233b..06f57c41 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -95,7 +95,7 @@ "/rest/v{version}/tags/{tag}/visits": { "$ref": "paths/v2_tags_{tag}_visits.json" }, - "/rest/v{version}/domain/{domain}/visits": { + "/rest/v{version}/domains/{domain}/visits": { "$ref": "paths/v2_domains_{domain}_visits.json" }, "/rest/v{version}/visits/orphan": { diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index b43d676d..51a0c333 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -154,6 +154,47 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $qb; } + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + } + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + $qb->select('COUNT(v.id)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFiltering $filtering): QueryBuilder + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later. + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Visit::class, 'v') + ->join('v.shortUrl', 's'); + + if ($domain === 'DEFAULT') { + $qb->where($qb->expr()->isNull('s.domain')); + } else { + $qb->join('s.domain', 'd') + ->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain))); + } + + if ($filtering->excludeBots()) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } + + $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); + + return $qb; + } + public function findOrphanVisits(VisitsListFiltering $filtering): array { $qb = $this->createAllVisitsQueryBuilder($filtering); diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 3d480c01..837dea1b 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int; + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array; + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int; + /** * @return Visit[] */ diff --git a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php new file mode 100644 index 00000000..508a7b36 --- /dev/null +++ b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php @@ -0,0 +1,49 @@ +visitRepository->countVisitsByDomain( + $this->domain, + new VisitsCountFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->apiKey, + ), + ); + } + + public function getSlice(int $offset, int $length): iterable + { + return $this->visitRepository->findVisitsByDomain( + $this->domain, + new VisitsListFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->apiKey, + $length, + $offset, + ), + ); + } +} diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 914a9c5b..3acac90d 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -19,6 +22,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; @@ -85,6 +89,24 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); } + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + { + /** @var DomainRepository $domainRepo */ + $domainRepo = $this->em->getRepository(Domain::class); + if ($domain !== 'DEFAULT' && $domainRepo->count(['authority' => $domain]) === 0) { + throw DomainNotFoundException::fromAuthority($domain); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + + return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); + } + /** * @return Visit[]|Paginator */ diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 3616b531..b32fc99d 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -33,6 +34,12 @@ interface VisitsStatsHelperInterface */ public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + /** * @return Visit[]|Paginator */ diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index a7a4b2ca..34be71f4 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -34,6 +34,7 @@ return [ Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class, @@ -73,6 +74,10 @@ return [ ], Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\Visit\DomainVisitsAction::class => [ + Visit\VisitsStatsHelper::class, + 'config.url_shortener.domain.hostname', + ], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\OrphanVisitsAction::class => [ Visit\VisitsStatsHelper::class, diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index c9b42579..f318664f 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -6,49 +6,52 @@ namespace Shlinkio\Shlink\Rest; use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; -$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; -$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; -$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; +return (static function (): array { + $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; + $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; + $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; -return [ + return [ - 'routes' => [ - Action\HealthAction::getRouteDef(), + 'routes' => [ + Action\HealthAction::getRouteDef(), - // Short URLs - Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $dropDomainMiddleware, - $overrideDomainMiddleware, - Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, - ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $overrideDomainMiddleware, - ]), - Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ListShortUrlsAction::getRouteDef(), + // Short URLs + Action\ShortUrl\CreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $dropDomainMiddleware, + $overrideDomainMiddleware, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, + ]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), + Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ListShortUrlsAction::getRouteDef(), - // Visits - Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), - Action\Visit\TagVisitsAction::getRouteDef(), - Action\Visit\GlobalVisitsAction::getRouteDef(), - Action\Visit\OrphanVisitsAction::getRouteDef(), - Action\Visit\NonOrphanVisitsAction::getRouteDef(), + // Visits + Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\TagVisitsAction::getRouteDef(), + Action\Visit\DomainVisitsAction::getRouteDef(), + Action\Visit\GlobalVisitsAction::getRouteDef(), + Action\Visit\OrphanVisitsAction::getRouteDef(), + Action\Visit\NonOrphanVisitsAction::getRouteDef(), - // Tags - Action\Tag\ListTagsAction::getRouteDef(), - Action\Tag\TagsStatsAction::getRouteDef(), - Action\Tag\DeleteTagsAction::getRouteDef(), - Action\Tag\UpdateTagAction::getRouteDef(), + // Tags + Action\Tag\ListTagsAction::getRouteDef(), + Action\Tag\TagsStatsAction::getRouteDef(), + Action\Tag\DeleteTagsAction::getRouteDef(), + Action\Tag\UpdateTagAction::getRouteDef(), - // Domains - Action\Domain\ListDomainsAction::getRouteDef(), - Action\Domain\DomainRedirectsAction::getRouteDef(), + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), - Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), - ], + Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), + ], -]; + ]; +})(); diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php new file mode 100644 index 00000000..b68d971f --- /dev/null +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -0,0 +1,48 @@ +resolveDomainParam($request); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); + } + + private function resolveDomainParam(Request $request): string + { + $domainParam = $request->getAttribute('domain', ''); + if ($domainParam === $this->defaultDomain) { + return 'DEFAULT'; + } + + return $domainParam; + } +}