Skip to content

Commit

Permalink
Merge pull request #2239 from acelaya-forks/feature/filter-by-domain
Browse files Browse the repository at this point in the history
Allow filtering short URLs by domain
  • Loading branch information
acelaya authored Oct 28, 2024
2 parents 468662b + a9869cd commit 4b1b583
Show file tree
Hide file tree
Showing 23 changed files with 159 additions and 51 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it.
* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`.

This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag.

### Changed
* *Nothing*
Expand Down
9 changes: 9 additions & 0 deletions docs/swagger/paths/v1_short-urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@
"false"
]
}
},
{
"name": "domain",
"in": "query",
"description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [
Expand Down
11 changes: 10 additions & 1 deletion module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
Expand Down Expand Up @@ -64,6 +65,12 @@ protected function configure(): void
InputOption::VALUE_REQUIRED,
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
->addOption(
'tags',
't',
Expand Down Expand Up @@ -134,6 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$domain = $input->getOption('domain');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
Expand All @@ -145,6 +153,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $domain,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
Expand Down Expand Up @@ -231,7 +240,7 @@ private function resolveColumnsMap(InputInterface $input): array
}
if ($input->getOption('show-domain')) {
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
$shortUrl->getDomain()?->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
Expand Down
3 changes: 2 additions & 1 deletion module/CLI/test-cli/Command/CreateShortUrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;

class CreateShortUrlTest extends CliTestCase
Expand All @@ -26,6 +27,6 @@ public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);

[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);
self::assertStringContainsString('DEFAULT', $listOutput);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput);
}
}
5 changes: 3 additions & 2 deletions module/CLI/test-cli/Command/ImportShortUrlsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Importer\Command\ImportCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;

Expand Down Expand Up @@ -66,10 +67,10 @@ public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1);
}
}
17 changes: 17 additions & 0 deletions module/CLI/test-cli/Command/ListShortUrlsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ public static function provideFlagsAndOutput(): iterable
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'non-default domain' => [['--domain=example.com'], <<<OUTPUT
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
OUTPUT];
yield 'default domain' => [['-d DEFAULT'], <<<OUTPUT
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
+------------+---------------+----------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
OUTPUT];
// phpcs:enable
}
}
4 changes: 2 additions & 2 deletions module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public function havingMorePagesButAnsweringNoCallsListJustOnce(): void
}

$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
ShortUrlsParams::empty(),
)->willReturn(new Paginator(new ArrayAdapter($data)));

$this->commandTester->setInputs(['n']);
Expand Down Expand Up @@ -110,7 +110,7 @@ public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
ApiKey $apiKey,
): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
ShortUrlsParams::emptyInstance(),
ShortUrlsParams::empty(),
)->willReturn(new Paginator(new ArrayAdapter([
ShortUrlWithVisitsSummary::fromShortUrl(
ShortUrl::create(ShortUrlCreation::fromRawData([
Expand Down
2 changes: 2 additions & 0 deletions module/Core/src/Domain/Entity/Domain.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface
{
public const DEFAULT_AUTHORITY = 'DEFAULT';

private function __construct(
public readonly string $authority,
private ?string $baseUrlRedirect = null,
Expand Down
6 changes: 4 additions & 2 deletions module/Core/src/ShortUrl/Model/ShortUrlsParams.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,18 @@ final class ShortUrlsParams
private function __construct(
public readonly int $page,
public readonly int $itemsPerPage,
public readonly ?string $searchTerm,
public readonly string|null $searchTerm,
public readonly array $tags,
public readonly Ordering $orderBy,
public readonly ?DateRange $dateRange,
public readonly bool $excludeMaxVisitsReached,
public readonly bool $excludePastValidUntil,
public readonly TagsMode $tagsMode = TagsMode::ANY,
public readonly string|null $domain = null,
) {
}

public static function emptyInstance(): self
public static function empty(): self
{
return self::fromRawData([]);
}
Expand Down Expand Up @@ -59,6 +60,7 @@ public static function fromRawData(array $query): self
excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED),
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
public const ORDER_BY = 'orderBy';
public const EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
public const EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
public const DOMAIN = 'domain';

public function __construct(array $data)
{
Expand Down Expand Up @@ -56,5 +57,7 @@ private function initialize(): void

$this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED));
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));

$this->add(InputFactory::basic(self::DOMAIN));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function __construct(
public readonly bool $excludePastValidUntil = false,
public readonly ?ApiKey $apiKey = null,
?string $defaultDomain = null,
public readonly ?string $domain = null,
) {
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
strtolower($defaultDomain),
Expand All @@ -43,6 +44,7 @@ public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, stri
$params->excludePastValidUntil,
$apiKey,
$defaultDomain,
$params->domain,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public function __construct(
bool $excludeMaxVisitsReached = false,
bool $excludePastValidUntil = false,
?ApiKey $apiKey = null,
// Used only to determine if search term includes default domain
?string $defaultDomain = null,
?string $domain = null,
) {
parent::__construct(
$searchTerm,
Expand All @@ -34,6 +36,7 @@ public function __construct(
$excludePastValidUntil,
$apiKey,
$defaultDomain,
$domain,
);
}

Expand All @@ -56,6 +59,7 @@ public static function fromLimitsAndParams(
$params->excludePastValidUntil,
$apiKey,
$defaultDomain,
$params->domain,
);
}
}
17 changes: 13 additions & 4 deletions module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
Expand Down Expand Up @@ -104,23 +105,22 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que

$searchTerm = $filtering->searchTerm;
$tags = $filtering->tags;
// 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
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}

// Apply general search conditions
// Apply search term to every "searchable" field
$conditions = [
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
];

// Include default domain in search if provided
if ($filtering->searchIncludesDefaultDomain) {
// Include default domain in search if included, and a domain was not explicitly provided
if ($filtering->searchIncludesDefaultDomain && $filtering->domain === null) {
$conditions[] = $qb->expr()->isNull('s.domain');
}

Expand All @@ -142,6 +142,15 @@ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): Que
: $this->joinAllTags($qb, $tags);
}

if ($filtering->domain !== null) {
if ($filtering->domain === Domain::DEFAULT_AUTHORITY) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} else {
$qb->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $filtering->domain);
}
}

if ($filtering->excludeMaxVisitsReached) {
$qb->andWhere($qb->expr()->orX(
$qb->expr()->isNull('s.maxVisits'),
Expand Down
3 changes: 2 additions & 1 deletion module/Core/src/Visit/Repository/VisitRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
Expand Down Expand Up @@ -124,7 +125,7 @@ private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFil
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's');

if ($domain === 'DEFAULT') {
if ($domain === Domain::DEFAULT_AUTHORITY) {
$qb->where($qb->expr()->isNull('s.domain'));
} else {
$qb->join('s.domain', 'd')
Expand Down
2 changes: 1 addition & 1 deletion module/Core/src/Visit/VisitsStatsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $a
{
/** @var DomainRepository $domainRepo */
$domainRepo = $this->em->getRepository(Domain::class);
if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) {
if ($domain !== Domain::DEFAULT_AUTHORITY && ! $domainRepo->domainExists($domain, $apiKey)) {
throw DomainNotFoundException::fromAuthority($domain);
}

Expand Down
Loading

0 comments on commit 4b1b583

Please sign in to comment.