Merge pull request #647 from shlinkio/develop

Release v2.0.4
This commit is contained in:
Alejandro Celaya 2020-02-02 20:23:09 +01:00 committed by GitHub
commit 57807c4360
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 1436 additions and 708 deletions

View File

@ -4,6 +4,32 @@ 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).
## 2.0.4 - 2020-02-02
#### Added
* *Nothing*
#### Changed
* [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted.
* [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API.
* [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code.
* [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name.
## 2.0.3 - 2020-01-27
#### Added

View File

@ -47,7 +47,7 @@
"phly/phly-event-dispatcher": "^1.0",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.5",
"shlinkio/shlink-common": "^2.7.0",
"shlinkio/shlink-event-dispatcher": "^1.3",
"shlinkio/shlink-installer": "^4.0.1",
"shlinkio/shlink-ip-geolocation": "^1.3.1",

View File

@ -9,6 +9,7 @@ return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
],
'connection' => [
'user' => '',

View File

@ -31,6 +31,10 @@
},
"meta": {
"$ref": "./ShortUrlMeta.json"
},
"domain": {
"type": "string",
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
}
}
}

View File

@ -0,0 +1,9 @@
{
"name": "domain",
"description": "The domain in which the short code should be searched for.",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}

View File

@ -123,7 +123,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
"domain": null
},
{
"shortCode": "12Kb3",
@ -138,11 +139,12 @@
"validSince": null,
"validUntil": null,
"maxVisits": null
}
},
"domain": null
},
{
"shortCode": "123bA",
"shortUrl": "https://doma.in/123bA",
"shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
@ -151,7 +153,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": null
}
},
"domain": "example.com"
}
],
"pagination": {
@ -271,7 +274,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 500
}
},
"domain": null
}
}
},

View File

@ -72,7 +72,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
"domain": null
},
"text/plain": "https://doma.in/abc123"
}

View File

@ -20,13 +20,7 @@
}
},
{
"name": "domain",
"in": "query",
"description": "The domain in which the short code should be searched for. Will fall back to default domain if not found.",
"required": false,
"schema": {
"type": "string"
}
"$ref": "../parameters/domain.json"
}
],
"security": [
@ -58,7 +52,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
"domain": null
}
}
},
@ -104,6 +99,9 @@
"schema": {
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
}
],
"requestBody": {
@ -214,6 +212,9 @@
"schema": {
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [

View File

@ -18,6 +18,9 @@
"schema": {
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
}
],
"requestBody": {

View File

@ -19,6 +19,9 @@
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
},
{
"name": "startDate",
"in": "query",

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -40,33 +41,39 @@ class DeleteShortUrlCommand extends Command
InputOption::VALUE_NONE,
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain if the short code does not belong to the default one',
);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$identifier = ShortUrlIdentifier::fromCli($input);
$ignoreThreshold = $input->getOption('ignore-threshold');
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
$this->runDelete($io, $identifier, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS;
} catch (Exception\ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
return $this->retry($io, $shortCode, $e->getMessage());
return $this->retry($io, $identifier, $e->getMessage());
}
}
private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int
private function retry(SymfonyStyle $io, ShortUrlIdentifier $identifier, string $warningMsg): int
{
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
if ($forceDelete) {
$this->runDelete($io, $shortCode, true);
$this->runDelete($io, $identifier, true);
} else {
$io->warning('Short URL was not deleted.');
}
@ -74,9 +81,9 @@ class DeleteShortUrlCommand extends Command
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
}
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode));
$this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold);
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode()));
}
}

View File

@ -9,10 +9,12 @@ 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\ShortUrlIdentifier;
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;
@ -36,7 +38,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get');
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code');
}
protected function getStartDateDesc(): string
@ -65,11 +68,11 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$shortCode = $input->getArgument('shortCode');
$identifier = ShortUrlIdentifier::fromCli($input);
$startDate = $this->getDateOption($input, $output, 'startDate');
$endDate = $this->getDateOption($input, $output, 'endDate');
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
$rowData = $visit->jsonSerialize();

View File

@ -4,16 +4,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@ -108,7 +109,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$orderBy = $this->processOrderBy($input);
do {
$result = $this->renderPage($output, $page, $searchTerm, $tags, $showTags, $startDate, $endDate, $orderBy);
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData([
ShortUrlsParamsInputFilter::PAGE => $page,
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsOrdering::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
]));
$page++;
$continue = $this->isLastPage($result)
@ -122,26 +130,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
return ExitCodes::EXIT_SUCCESS;
}
/**
* @param string|array|null $orderBy
*/
private function renderPage(
OutputInterface $output,
int $page,
?string $searchTerm,
array $tags,
bool $showTags,
?Chronos $startDate,
?Chronos $endDate,
$orderBy
): Paginator {
$result = $this->shortUrlService->listShortUrls(
$page,
$searchTerm,
$tags,
$orderBy,
new DateRange($startDate, $endDate),
);
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params): Paginator
{
$result = $this->shortUrlService->listShortUrls($params);
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -54,11 +55,9 @@ class ResolveUrlCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$domain = $input->getOption('domain');
try {
$url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) {

View File

@ -9,6 +9,7 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -38,8 +39,10 @@ class DeleteShortUrlCommandTest extends TestCase
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function (): void {
});
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will(
function (): void {
},
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@ -55,8 +58,9 @@ class DeleteShortUrlCommandTest extends TestCase
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode),
$identifier = new ShortUrlIdentifier($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
@ -76,7 +80,8 @@ class DeleteShortUrlCommandTest extends TestCase
string $expectedMessage
): void {
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
$identifier = new ShortUrlIdentifier($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
function (array $args) use ($shortCode): void {
$ignoreThreshold = array_pop($args);
@ -109,7 +114,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode),
);
$this->commandTester->setInputs(['no']);

View File

@ -15,6 +15,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
@ -42,9 +43,12 @@ class GetVisitsCommandTest extends TestCase
public function noDateFlagsTriesToListWithoutDateRange(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
new Paginator(new ArrayAdapter([])),
)->shouldBeCalledOnce();
$this->visitsTracker->info(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(null, null)),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
}
@ -56,7 +60,7 @@ class GetVisitsCommandTest extends TestCase
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsTracker->info(
$shortCode,
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
@ -74,7 +78,7 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$startDate = 'foo';
$info = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange()))
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
@ -94,7 +98,7 @@ class GetVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),

View File

@ -11,8 +11,8 @@ 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\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -64,7 +64,7 @@ class ListShortUrlsCommandTest extends TestCase
$data[] = new ShortUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledOnce();
@ -85,7 +85,7 @@ class ListShortUrlsCommandTest extends TestCase
public function passingPageWillMakeListStartOnThatPage(): void
{
$page = 5;
$this->shortUrlService->listShortUrls($page, null, [], null, new DateRange())
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledOnce();
@ -96,7 +96,7 @@ class ListShortUrlsCommandTest extends TestCase
/** @test */
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
{
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledOnce();
@ -115,10 +115,16 @@ class ListShortUrlsCommandTest extends TestCase
?int $page,
?string $searchTerm,
array $tags,
?DateRange $dateRange
?string $startDate = null,
?string $endDate = null
): void {
$listShortUrls = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, null, $dateRange)
->willReturn(new Paginator(new ArrayAdapter()));
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'page' => $page,
'searchTerm' => $searchTerm,
'tags' => $tags,
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
]))->willReturn(new Paginator(new ArrayAdapter()));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
@ -128,36 +134,37 @@ class ListShortUrlsCommandTest extends TestCase
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 [[], 1, null, []];
yield [['--page' => $page = 3], $page, null, []];
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
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)),
$startDate,
];
yield [
['--endDate' => $endDate = '2020-05-23'],
1,
null,
[],
new DateRange(null, Chronos::parse($endDate)),
null,
$endDate,
];
yield [
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
1,
null,
[],
new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)),
$startDate,
$endDate,
];
}
@ -168,8 +175,9 @@ class ListShortUrlsCommandTest extends TestCase
*/
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
{
$listShortUrls = $this->shortUrlService->listShortUrls(1, null, [], $expectedOrderBy, new DateRange())
->willReturn(new Paginator(new ArrayAdapter()));
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,
]))->willReturn(new Paginator(new ArrayAdapter()));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);

View File

@ -9,6 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -38,8 +39,8 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@ -49,9 +50,11 @@ class ResolveUrlCommandTest extends TestCase
/** @test */
public function incorrectShortCodeOutputsErrorMessage(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToShortUrl($shortCode, null)
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
$identifier = new ShortUrlIdentifier('abc123');
$shortCode = $identifier->shortCode();
$this->urlResolver->resolveShortUrl($identifier)
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);

View File

@ -53,10 +53,14 @@ return [
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
Service\ShortUrlService::class => ['em'],
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class],
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
Service\ShortUrl\DeleteShortUrlService::class => [
'em',
Options\DeleteShortUrlsOptions::class,
Service\ShortUrl\ShortUrlResolver::class,
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Util\UrlValidator::class => ['httpClient'],

View File

@ -6,20 +6,21 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('domains');
$builder->setTable(determineTableName('domains', $emConfig));
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('authority', Types::STRING)
->unique()
->build();
$builder->createField('authority', Types::STRING)
->unique()
->build();
};

View File

@ -6,65 +6,66 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('short_urls')
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
$builder->setTable(determineTableName('short_urls', $emConfig))
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('longUrl', Types::STRING)
->columnName('original_url')
->length(2048)
->build();
$builder->createField('longUrl', Types::STRING)
->columnName('original_url')
->length(2048)
->build();
$builder->createField('shortCode', Types::STRING)
->columnName('short_code')
->length(255)
->build();
$builder->createField('shortCode', Types::STRING)
->columnName('short_code')
->length(255)
->build();
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('date_created')
->build();
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('date_created')
->build();
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_since')
->nullable()
->build();
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_since')
->nullable()
->build();
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_until')
->nullable()
->build();
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_until')
->nullable()
->build();
$builder->createField('maxVisits', Types::INTEGER)
->columnName('max_visits')
->nullable()
->build();
$builder->createField('maxVisits', Types::INTEGER)
->columnName('max_visits')
->nullable()
->build();
$builder->createOneToMany('visits', Entity\Visit::class)
->mappedBy('shortUrl')
->fetchExtraLazy()
->build();
$builder->createOneToMany('visits', Entity\Visit::class)
->mappedBy('shortUrl')
->fetchExtraLazy()
->build();
$builder->createManyToMany('tags', Entity\Tag::class)
->setJoinTable('short_urls_in_tags')
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToMany('tags', Entity\Tag::class)
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToOne('domain', Entity\Domain::class)
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
->cascadePersist()
->build();
$builder->createManyToOne('domain', Entity\Domain::class)
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
->cascadePersist()
->build();
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
};

View File

@ -6,21 +6,22 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('tags')
->setCustomRepositoryClass(Repository\TagRepository::class);
$builder->setTable(determineTableName('tags', $emConfig))
->setCustomRepositoryClass(Repository\TagRepository::class);
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('name', Types::STRING)
->unique()
->build();
$builder->createField('name', Types::STRING)
->unique()
->build();
};

View File

@ -6,49 +6,50 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\Model\Visitor;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('visits')
->setCustomRepositoryClass(Repository\VisitRepository::class);
$builder->setTable(determineTableName('visits', $emConfig))
->setCustomRepositoryClass(Repository\VisitRepository::class);
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('referer', Types::STRING)
->nullable()
->length(Visitor::REFERER_MAX_LENGTH)
->build();
$builder->createField('referer', Types::STRING)
->nullable()
->length(Visitor::REFERER_MAX_LENGTH)
->build();
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('`date`')
->build();
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('`date`')
->build();
$builder->createField('remoteAddr', Types::STRING)
->columnName('remote_addr')
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
->nullable()
->build();
$builder->createField('remoteAddr', Types::STRING)
->columnName('remote_addr')
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
->nullable()
->build();
$builder->createField('userAgent', Types::STRING)
->columnName('user_agent')
->length(Visitor::USER_AGENT_MAX_LENGTH)
->nullable()
->build();
$builder->createField('userAgent', Types::STRING)
->columnName('user_agent')
->length(Visitor::USER_AGENT_MAX_LENGTH)
->nullable()
->build();
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
->build();
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
->build();
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
->cascadePersist()
->build();
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
->cascadePersist()
->build();
};

View File

@ -6,41 +6,42 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('visit_locations');
$builder->setTable(determineTableName('visit_locations', $emConfig));
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$columns = [
'country_code' => 'countryCode',
'country_name' => 'countryName',
'region_name' => 'regionName',
'city_name' => 'cityName',
'timezone' => 'timezone',
];
foreach ($columns as $columnName => $fieldName) {
$builder->createField($fieldName, Types::STRING)
->columnName($columnName)
->nullable()
$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
}
$builder->createField('latitude', Types::FLOAT)
->columnName('lat')
->nullable(false)
->build();
$columns = [
'country_code' => 'countryCode',
'country_name' => 'countryName',
'region_name' => 'regionName',
'city_name' => 'cityName',
'timezone' => 'timezone',
];
$builder->createField('longitude', Types::FLOAT)
->columnName('lon')
->nullable(false)
->build();
foreach ($columns as $columnName => $fieldName) {
$builder->createField($fieldName, Types::STRING)
->columnName($columnName)
->nullable()
->build();
}
$builder->createField('latitude', Types::FLOAT)
->columnName('lat')
->nullable(false)
->build();
$builder->createField('longitude', Types::FLOAT)
->columnName('lon')
->nullable(false)
->build();
};

View File

@ -4,8 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use PUGX\Shortid\Factory as ShortIdFactory;
use function sprintf;
function generateRandomShortCode(int $length = 5): string
{
static $shortIdFactory;
@ -16,3 +20,36 @@ function generateRandomShortCode(int $length = 5): string
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
return $shortIdFactory->generate($length, $alphabet)->serialize();
}
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
}
/**
* @param string|DateTimeInterface|Chronos|null $date
*/
function parseDateField($date): ?Chronos
{
if ($date === null || $date instanceof Chronos) {
return $date;
}
if ($date instanceof DateTimeInterface) {
return Chronos::instance($date);
}
return Chronos::parse($date);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
if ($schema === null) {
return $tableName;
}
return sprintf('%s.%s', $schema, $tableName);
}

View File

@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
@ -44,17 +45,16 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
$domain = $request->getUri()->getAuthority();
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
$query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$url = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
$url = $this->urlResolver->resolveEnabledShortUrl($identifier);
// Track visit to this short code
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, Visitor::fromRequest($request));
$this->visitTracker->track($url, Visitor::fromRequest($request));
}
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));

View File

@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeAction implements MiddlewareInterface
@ -38,18 +39,16 @@ class QrCodeAction implements MiddlewareInterface
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Make sure the short URL exists for this short code
$shortCode = $request->getAttribute('shortCode');
$domain = $request->getUri()->getAuthority();
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
try {
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
$this->urlResolver->resolveEnabledShortUrl($identifier);
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request);
}
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $shortCode]);
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $identifier->shortCode()]);
$size = $this->getSizeParam($request);
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));

View File

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Domain extends AbstractEntity
class Domain extends AbstractEntity implements JsonSerializable
{
private string $authority;
@ -19,4 +20,9 @@ class Domain extends AbstractEntity
{
return $this->authority;
}
public function jsonSerialize(): string
{
return $this->getAuthority();
}
}

View File

@ -69,6 +69,11 @@ class ShortUrl extends AbstractEntity
return $this->dateCreated;
}
public function getDomain(): ?Domain
{
return $this->domain;
}
/**
* @return Collection|Tag[]
*/

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use function sprintf;
@ -17,8 +18,10 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
private const TITLE = 'Short URL not found';
private const TYPE = 'INVALID_SHORTCODE';
public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self
public static function fromNotFound(ShortUrlIdentifier $identifier): self
{
$shortCode = $identifier->shortCode();
$domain = $identifier->domain();
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Console\Input\InputInterface;
final class ShortUrlIdentifier
{
private string $shortCode;
private ?string $domain;
public function __construct(string $shortCode, ?string $domain = null)
{
$this->shortCode = $shortCode;
$this->domain = $domain;
}
public static function fromApiRequest(ServerRequestInterface $request): self
{
$shortCode = $request->getAttribute('shortCode', '');
$domain = $request->getQueryParams()['domain'] ?? null;
return new self($shortCode, $domain);
}
public static function fromRedirectRequest(ServerRequestInterface $request): self
{
$shortCode = $request->getAttribute('shortCode', '');
$domain = $request->getUri()->getAuthority();
return new self($shortCode, $domain);
}
public static function fromCli(InputInterface $input): self
{
$shortCode = $input->getArguments()['shortCode'] ?? '';
$domain = $input->getOptions()['domain'] ?? null;
return new self($shortCode, $domain);
}
public function shortCode(): string
{
return $this->shortCode;
}
public function domain(): ?string
{
return $this->domain;
}
}

View File

@ -5,11 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlMeta
{
@ -34,30 +34,28 @@ final class ShortUrlMeta
}
/**
* @param array $data
* @throws ValidationException
*/
public static function fromRawData(array $data): self
{
$instance = new self();
$instance->validate($data);
$instance->validateAndInit($data);
return $instance;
}
/**
* @param array $data
* @throws ValidationException
*/
private function validate(array $data): void
private function validateAndInit(array $data): void
{
$inputFilter = new ShortUrlMetaInputFilter($data);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
@ -67,22 +65,6 @@ final class ShortUrlMeta
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
}
/**
* @param string|DateTimeInterface|Chronos|null $date
*/
private function parseDateField($date): ?Chronos
{
if ($date === null || $date instanceof Chronos) {
return $date;
}
if ($date instanceof DateTimeInterface) {
return Chronos::instance($date);
}
return Chronos::parse($date);
}
public function getValidSince(): ?Chronos
{
return $this->validSince;

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function is_array;
use function is_string;
use function key;
final class ShortUrlsOrdering
{
public const ORDER_BY = 'orderBy';
private const DEFAULT_ORDER_DIRECTION = 'ASC';
private ?string $orderField = null;
private string $orderDirection = self::DEFAULT_ORDER_DIRECTION;
/**
* @throws ValidationException
*/
public static function fromRawData(array $query): self
{
$instance = new self();
$instance->validateAndInit($query);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $data): void
{
/** @var string|array|null $orderBy */
$orderBy = $data[self::ORDER_BY] ?? null;
if ($orderBy === null) {
return;
}
$isArray = is_array($orderBy);
if (! $isArray && $orderBy !== null && ! is_string($orderBy)) {
throw ValidationException::fromArray([
'orderBy' => '"Order by" must be an array, string or null',
]);
}
$this->orderField = $isArray ? key($orderBy) : $orderBy;
$this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION;
}
public function orderField(): ?string
{
return $this->orderField;
}
public function orderDirection(): string
{
return $this->orderDirection;
}
public function hasOrderField(): bool
{
return $this->orderField !== null;
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlsParams
{
private int $page;
private ?string $searchTerm;
private array $tags;
private ShortUrlsOrdering $orderBy;
private ?DateRange $dateRange;
private function __construct()
{
}
public static function emptyInstance(): self
{
return self::fromRawData([]);
}
/**
* @throws ValidationException
*/
public static function fromRawData(array $query): self
{
$instance = new self();
$instance->validateAndInit($query);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $query): void
{
$inputFilter = new ShortUrlsParamsInputFilter($query);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = new DateRange(
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
}
public function page(): int
{
return $this->page;
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function orderBy(): ShortUrlsOrdering
{
return $this->orderBy;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
}

View File

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Util\DateRange;
use function Shlinkio\Shlink\Core\parseDateFromQuery;
final class VisitsParams
{
private const FIRST_PAGE = 1;
@ -34,21 +35,13 @@ final class VisitsParams
public static function fromRawData(array $query): self
{
$startDate = self::getDateQueryParam($query, 'startDate');
$endDate = self::getDateQueryParam($query, 'endDate');
return new self(
new DateRange($startDate, $endDate),
new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')),
(int) ($query['page'] ?? 1),
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
);
}
private static function getDateQueryParam(array $query, string $key): ?Chronos
{
return ! isset($query[$key]) || empty($query[$key]) ? null : Chronos::parse($query[$key]);
}
public function getDateRange(): DateRange
{
return $this->dateRange;

View File

@ -5,38 +5,20 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Laminas\Paginator\Adapter\AdapterInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use function strip_tags;
use function trim;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
public const ITEMS_PER_PAGE = 10;
private ShortUrlRepositoryInterface $repository;
private ?string $searchTerm;
/** @var null|array|string */
private $orderBy;
private array $tags;
private ?DateRange $dateRange;
private ShortUrlsParams $params;
/**
* @param string|array|null $orderBy
*/
public function __construct(
ShortUrlRepositoryInterface $repository,
?string $searchTerm = null,
array $tags = [],
$orderBy = null,
?DateRange $dateRange = null
) {
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params)
{
$this->repository = $repository;
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
$this->orderBy = $orderBy;
$this->tags = $tags;
$this->dateRange = $dateRange;
$this->params = $params;
}
/**
@ -50,10 +32,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
return $this->repository->findList(
$itemCountPerPage,
$offset,
$this->searchTerm,
$this->tags,
$this->orderBy,
$this->dateRange,
$this->params->searchTerm(),
$this->params->tags(),
$this->params->orderBy(),
$this->params->dateRange(),
);
}
@ -68,6 +50,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
*/
public function count(): int
{
return $this->repository->countList($this->searchTerm, $this->tags, $this->dateRange);
return $this->repository->countList(
$this->params->searchTerm(),
$this->params->tags(),
$this->params->dateRange(),
);
}
}

View File

@ -5,26 +5,31 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Laminas\Paginator\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsPaginatorAdapter implements AdapterInterface
{
private VisitRepositoryInterface $visitRepository;
private string $shortCode;
private ShortUrlIdentifier $identifier;
private VisitsParams $params;
public function __construct(VisitRepositoryInterface $visitRepository, string $shortCode, VisitsParams $params)
{
public function __construct(
VisitRepositoryInterface $visitRepository,
ShortUrlIdentifier $identifier,
VisitsParams $params
) {
$this->visitRepository = $visitRepository;
$this->shortCode = $shortCode;
$this->params = $params;
$this->identifier = $identifier;
}
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
{
return $this->visitRepository->findVisitsByShortCode(
$this->shortCode,
$this->identifier->shortCode(),
$this->identifier->domain(),
$this->params->getDateRange(),
$itemCountPerPage,
$offset,
@ -33,6 +38,10 @@ class VisitsPaginatorAdapter implements AdapterInterface
public function count(): int
{
return $this->visitRepository->countVisitsByShortCode($this->shortCode, $this->params->getDateRange());
return $this->visitRepository->countVisitsByShortCode(
$this->identifier->shortCode(),
$this->identifier->domain(),
$this->params->getDateRange(),
);
}
}

View File

@ -8,18 +8,16 @@ use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use function array_column;
use function array_key_exists;
use function Functional\contains;
use function is_array;
use function key;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
{
/**
* @param string[] $tags
* @param string|array|null $orderBy
* @return ShortUrl[]
*/
public function findList(
@ -27,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
?int $offset = null,
?string $searchTerm = null,
array $tags = [],
$orderBy = null,
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
@ -42,7 +40,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
}
// In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy !== null) {
if ($orderBy !== null && $orderBy->hasOrderField()) {
return $this->processOrderByForList($qb, $orderBy);
}
@ -51,14 +49,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
return $qb->getQuery()->getResult();
}
/**
* @param string|array|null $orderBy
*/
private function processOrderByForList(QueryBuilder $qb, $orderBy): array
private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
{
$isArray = is_array($orderBy);
$fieldName = $isArray ? key($orderBy) : $orderBy;
$order = $isArray ? $orderBy[$fieldName] : 'ASC';
$fieldName = $orderBy->orderField();
$order = $orderBy->orderDirection();
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
@ -96,8 +90,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
?DateRange $dateRange = null
): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's');
$qb->where('1=1');
$qb->from(ShortUrl::class, 's')
->where('1=1');
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
@ -116,12 +110,14 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
}
// 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'),
));
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
$qb->leftJoin('s.domain', 'd')
->andWhere($qb->expr()->orX(
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('t.name', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
))
->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
@ -133,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
return $qb;
}
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl
{
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
// the bottom
@ -165,14 +161,30 @@ DQL;
return $query->getOneOrNullResult();
}
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl
{
$qb = $this->createFindOneQueryBuilder($shortCode, $domain);
$qb->select('s');
return $qb->getQuery()->getOneOrNullResult();
}
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
{
$qb = $this->createFindOneQueryBuilder($slug, $domain);
$qb->select('COUNT(DISTINCT s.id)');
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(DISTINCT s.id)')
->from(ShortUrl::class, 's')
$qb->from(ShortUrl::class, 's')
->where($qb->expr()->isNotNull('s.shortCode'))
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
->setParameter('slug', $slug);
->setParameter('slug', $slug)
->setMaxResults(1);
if ($domain !== null) {
$qb->join('s.domain', 'd')
@ -182,7 +194,6 @@ DQL;
$qb->andWhere($qb->expr()->isNull('s.domain'));
}
$result = (int) $qb->getQuery()->getSingleScalarResult();
return $result > 0;
return $qb;
}
}

View File

@ -7,24 +7,24 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
interface ShortUrlRepositoryInterface extends ObjectRepository
{
/**
* @param string|array|null $orderBy
*/
public function findList(
?int $limit = null,
?int $offset = null,
?string $searchTerm = null,
array $tags = [],
$orderBy = null,
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null
): array;
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
}

View File

@ -46,11 +46,12 @@ DQL;
*/
public function findVisitsByShortCode(
string $shortCode,
?string $domain = null,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
): array {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('v')
->orderBy('v.date', 'DESC');
@ -64,22 +65,34 @@ DQL;
return $qb->getQuery()->getResult();
}
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
{
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('COUNT(DISTINCT v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByShortCodeQueryBuilder(string $shortCode, ?DateRange $dateRange = null): QueryBuilder
{
private function createVisitsByShortCodeQueryBuilder(
string $shortCode,
?string $domain,
?DateRange $dateRange
): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 'su')
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
->setParameter('shortCode', $shortCode);
// Apply domain filtering
if ($domain !== null) {
$qb->join('su.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $domain);
} else {
$qb->andWhere($qb->expr()->isNull('su.domain'));
}
// Apply date range filtering
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))

View File

@ -28,10 +28,15 @@ interface VisitRepositoryInterface extends ObjectRepository
*/
public function findVisitsByShortCode(
string $shortCode,
?string $domain = null,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
): array;
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int;
public function countVisitsByShortCode(
string $shortCode,
?string $domain = null,
?DateRange $dateRange = null
): int;
}

View File

@ -7,28 +7,32 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
use FindShortCodeTrait;
private EntityManagerInterface $em;
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
private ShortUrlResolverInterface $urlResolver;
public function __construct(EntityManagerInterface $em, DeleteShortUrlsOptions $deleteShortUrlsOptions)
{
public function __construct(
EntityManagerInterface $em,
DeleteShortUrlsOptions $deleteShortUrlsOptions,
ShortUrlResolverInterface $urlResolver
) {
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
$this->urlResolver = $urlResolver;
}
/**
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void
{
$shortUrl = $this->findByShortCode($this->em, $shortCode);
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->deleteShortUrlsOptions->getVisitsThreshold(),

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
interface DeleteShortUrlServiceInterface
{
@ -12,5 +13,5 @@ interface DeleteShortUrlServiceInterface
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void;
}

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
trait FindShortCodeTrait
{
/**
* @throws ShortUrlNotFoundException
*/
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;
}
}

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortUrlResolver implements ShortUrlResolverInterface
@ -21,13 +22,13 @@ class ShortUrlResolver implements ShortUrlResolverInterface
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
$shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain());
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
return $shortUrl;
@ -36,11 +37,13 @@ class ShortUrlResolver implements ShortUrlResolverInterface
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl
{
$shortUrl = $this->shortCodeToShortUrl($shortCode, $domain);
if (! $shortUrl->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain());
if ($shortUrl === null || ! $shortUrl->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
return $shortUrl;

View File

@ -6,16 +6,17 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
interface ShortUrlResolverInterface
{
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
}

View File

@ -6,45 +6,39 @@ namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\FindShortCodeTrait;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
class ShortUrlService implements ShortUrlServiceInterface
{
use FindShortCodeTrait;
use TagManagerTrait;
private ORM\EntityManagerInterface $em;
private ShortUrlResolverInterface $urlResolver;
public function __construct(ORM\EntityManagerInterface $em)
public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver)
{
$this->em = $em;
$this->urlResolver = $urlResolver;
}
/**
* @param string[] $tags
* @param array|string|null $orderBy
*
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(
int $page = 1,
?string $searchQuery = null,
array $tags = [],
$orderBy = null,
?DateRange $dateRange = null
) {
public function listShortUrls(ShortUrlsParams $params): Paginator
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $searchQuery, $tags, $orderBy, $dateRange));
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
$paginator->setItemCountPerPage(ShortUrlRepositoryAdapter::ITEMS_PER_PAGE)
->setCurrentPageNumber($page);
->setCurrentPageNumber($params->page());
return $paginator;
}
@ -53,10 +47,11 @@ class ShortUrlService implements ShortUrlServiceInterface
* @param string[] $tags
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl
{
$shortUrl = $this->findByShortCode($this->em, $shortCode);
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
return $shortUrl;
@ -65,9 +60,9 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @throws ShortUrlNotFoundException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl
{
$shortUrl = $this->findByShortCode($this->em, $shortCode);
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
$shortUrl->updateMeta($shortUrlMeta);
$this->em->flush();

View File

@ -5,35 +5,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
interface ShortUrlServiceInterface
{
/**
* @param string[] $tags
* @param array|string|null $orderBy
*
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(
int $page = 1,
?string $searchQuery = null,
array $tags = [],
$orderBy = null,
?DateRange $dateRange = null
);
public function listShortUrls(ShortUrlsParams $params): Paginator;
/**
* @param string[] $tags
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl;
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl;
}

View File

@ -11,9 +11,11 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
class VisitsTracker implements VisitsTrackerInterface
@ -30,13 +32,8 @@ class VisitsTracker implements VisitsTrackerInterface
/**
* Tracks a new visit to provided short code from provided visitor
*/
public function track(string $shortCode, Visitor $visitor): void
public function track(ShortUrl $shortUrl, Visitor $visitor): void
{
/** @var ShortUrl $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
$visit = new Visit($shortUrl, $visitor);
$this->em->persist($visit);
@ -51,17 +48,17 @@ class VisitsTracker implements VisitsTrackerInterface
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(string $shortCode, VisitsParams $params): Paginator
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator
{
/** @var ORM\EntityRepository $repo */
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
if ($repo->count(['shortCode' => $shortCode]) < 1) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepository $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $shortCode, $params));
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
$paginator->setItemCountPerPage($params->getItemsPerPage())
->setCurrentPageNumber($params->getPage());

View File

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
@ -15,7 +17,7 @@ interface VisitsTrackerInterface
/**
* Tracks a new visit to provided short code from provided visitor
*/
public function track(string $shortCode, Visitor $visitor): void;
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
/**
* Returns the visits on certain short code
@ -23,5 +25,5 @@ interface VisitsTrackerInterface
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(string $shortCode, VisitsParams $params): Paginator;
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
}

View File

@ -24,16 +24,15 @@ class ShortUrlDataTransformer implements DataTransformerInterface
*/
public function transform($shortUrl): array // phpcs:ignore
{
$longUrl = $shortUrl->getLongUrl();
return [
'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => $shortUrl->toString($this->domainConfig),
'longUrl' => $longUrl,
'longUrl' => $shortUrl->getLongUrl(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'visitsCount' => $shortUrl->getVisitsCount(),
'tags' => invoke($shortUrl->getTags(), '__toString'),
'meta' => $this->buildMeta($shortUrl),
'domain' => $shortUrl->getDomain(),
];
}

View File

@ -20,12 +20,10 @@ class ShortUrlMetaInputFilter extends InputFilter
public const FIND_IF_EXISTS = 'findIfExists';
public const DOMAIN = 'domain';
public function __construct(?array $data = null)
public function __construct(array $data)
{
$this->initialize();
if ($data !== null) {
$this->setData($data);
}
$this->setData($data);
}
private function initialize(): void

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Laminas\Filter;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
class ShortUrlsParamsInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
public const PAGE = 'page';
public const SEARCH_TERM = 'searchTerm';
public const TAGS = 'tags';
public const START_DATE = 'startDate';
public const END_DATE = 'endDate';
public function __construct(array $data)
{
$this->initialize();
$this->setData($data);
}
private function initialize(): void
{
$this->add($this->createDateInput(self::START_DATE, false));
$this->add($this->createDateInput(self::END_DATE, false));
$this->add($this->createInput(self::SEARCH_TERM, false));
$page = $this->createInput(self::PAGE, false);
$page->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
$this->add($page);
$tags = $this->createArrayInput(self::TAGS, false);
$tags->getFilterChain()->attach(new Filter\StringToLower())
->attach(new Validation\SluggerFilter());
$this->add($tags);
}
}

View File

@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
@ -36,7 +37,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
}
/** @test */
public function findOneByShortCodeReturnsProperData(): void
public function findOneWithDomainFallbackReturnsProperData(): void
{
$regularOne = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
$this->getEntityManager()->persist($regularOne);
@ -53,20 +54,25 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertSame($regularOne, $this->repo->findOneByShortCode($regularOne->getShortCode()));
$this->assertSame($regularOne, $this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode()));
$this->assertSame($withDomain, $this->repo->findOneByShortCode($withDomain->getShortCode(), 'example.com'));
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode()));
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback(
$withDomainDuplicatingRegular->getShortCode(),
));
$this->assertSame($withDomain, $this->repo->findOneWithDomainFallback(
$withDomain->getShortCode(),
'example.com',
));
$this->assertSame(
$withDomainDuplicatingRegular,
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
);
$this->assertSame(
$regularOne,
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
);
$this->assertNull($this->repo->findOneByShortCode('invalid'));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com'));
$this->assertNull($this->repo->findOneWithDomainFallback('invalid'));
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode()));
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com'));
}
/** @test */
@ -122,7 +128,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertCount(1, $this->repo->findList(2, 2));
$result = $this->repo->findList(null, null, null, [], ['visits' => 'DESC']);
$result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
'orderBy' => ['visits' => 'DESC'],
]));
$this->assertCount(3, $result);
$this->assertSame($bar, $result[0]);
@ -148,7 +156,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, null, [], ['longUrl' => 'ASC']);
$result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
'orderBy' => ['longUrl' => 'ASC'],
]));
$this->assertCount(count($urls), $result);
$this->assertEquals('a', $result[0]->getLongUrl());
@ -178,4 +188,26 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com'));
$this->assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in'));
}
/** @test */
public function findOneLooksForShortUrlInProperSetOfTables(): void
{
$shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug']));
$this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = new ShortUrl(
'foo',
ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']),
);
$this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush();
$this->assertNotNull($this->repo->findOne('my-cool-slug'));
$this->assertNull($this->repo->findOne('my-cool-slug', 'doma.in'));
$this->assertNull($this->repo->findOne('slug-not-in-use'));
$this->assertNull($this->repo->findOne('another-slug'));
$this->assertNull($this->repo->findOne('another-slug', 'example.com'));
$this->assertNotNull($this->repo->findOne('another-slug', 'doma.in'));
}
}

View File

@ -6,9 +6,11 @@ namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
@ -24,6 +26,7 @@ class VisitRepositoryTest extends DatabaseTestCase
VisitLocation::class,
Visit::class,
ShortUrl::class,
Domain::class,
];
private VisitRepository $repo;
@ -72,48 +75,73 @@ class VisitRepositoryTest extends DatabaseTestCase
/** @test */
public function findVisitsByShortCodeReturnsProperData(): void
{
$shortUrl = new ShortUrl('');
$this->getEntityManager()->persist($shortUrl);
for ($i = 0; $i < 6; $i++) {
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
$this->getEntityManager()->persist($visit);
}
$this->getEntityManager()->flush();
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
$this->assertCount(0, $this->repo->findVisitsByShortCode('invalid'));
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortUrl->getShortCode()));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortCode));
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 3, 2));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 5, 4));
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4));
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2));
}
/** @test */
public function countVisitsByShortCodeReturnsProperData(): void
{
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortCode));
$this->assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain));
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange(
Chronos::parse('2016-01-03'),
)));
}
private function createShortUrlsAndVisits(): array
{
$shortUrl = new ShortUrl('');
$domain = 'example.com';
$shortCode = $shortUrl->getShortCode();
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->persist($shortUrlWithDomain);
for ($i = 0; $i < 6; $i++) {
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
$this->getEntityManager()->persist($visit);
}
for ($i = 0; $i < 3; $i++) {
$visit = new Visit(
$shortUrlWithDomain,
Visitor::emptyInstance(),
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
);
$this->getEntityManager()->persist($visit);
}
$this->getEntityManager()->flush();
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortUrl->getShortCode()));
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
Chronos::parse('2016-01-03'),
)));
return [$shortCode, $domain];
}
}

View File

@ -12,6 +12,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Action\PixelAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
@ -38,7 +39,7 @@ class PixelActionTest extends TestCase
public function imageIsReturned(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn(
new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();

View File

@ -15,6 +15,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeActionTest extends TestCase
@ -36,8 +37,9 @@ class QrCodeActionTest extends TestCase
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -50,8 +52,9 @@ class QrCodeActionTest extends TestCase
public function anInvalidShortCodeWillReturnNotFoundResponse(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -64,8 +67,9 @@ class QrCodeActionTest extends TestCase
public function aCorrectRequestReturnsTheQrCodeResponse(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process(

View File

@ -13,6 +13,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
@ -45,7 +46,8 @@ class RedirectActionTest extends TestCase
{
$shortCode = 'abc123';
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
$shortCodeToUrl = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn($shortUrl);
$shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
});
@ -74,8 +76,9 @@ class RedirectActionTest extends TestCase
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$handler = $this->prophesize(RequestHandlerInterface::class);

View File

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
class ShortUrlNotFoundExceptionTest extends TestCase
{
@ -23,7 +24,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase
$expectedAdditional['domain'] = $domain;
}
$e = ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
$e = ShortUrlNotFoundException::fromNotFound(new ShortUrlIdentifier($shortCode, $domain));
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());

View File

@ -7,7 +7,7 @@ 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\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
@ -21,17 +21,26 @@ class ShortUrlRepositoryAdapterTest extends TestCase
}
/**
* @param string|array|null $orderBy
* @test
* @dataProvider provideFilteringArgs
*/
public function getItemsFallsBackToFindList(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null,
$orderBy = null
?string $startDate = null,
?string $endDate = null,
?string $orderBy = null
): void {
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, $orderBy, $dateRange);
$params = ShortUrlsParams::fromRawData([
'searchTerm' => $searchTerm,
'tags' => $tags,
'startDate' => $startDate,
'endDate' => $endDate,
'orderBy' => $orderBy,
]);
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
$orderBy = $params->orderBy();
$dateRange = $params->dateRange();
$this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce();
$adapter->getItems(5, 10);
@ -44,9 +53,17 @@ class ShortUrlRepositoryAdapterTest extends TestCase
public function countFallsBackToCountList(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null
?string $startDate = null,
?string $endDate = null
): void {
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, null, $dateRange);
$params = ShortUrlsParams::fromRawData([
'searchTerm' => $searchTerm,
'tags' => $tags,
'startDate' => $startDate,
'endDate' => $endDate,
]);
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
$dateRange = $params->dateRange();
$this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce();
$adapter->count();
@ -58,12 +75,12 @@ class ShortUrlRepositoryAdapterTest extends TestCase
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())];
yield ['search', ['foo', 'bar'], null, null, 'order'];
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'order'];
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'order'];
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString()];
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString()];
}
}

View File

@ -12,10 +12,11 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use function Functional\map;
use function range;
@ -24,20 +25,20 @@ use function sprintf;
class DeleteShortUrlServiceTest extends TestCase
{
private ObjectProphecy $em;
private ObjectProphecy $urlResolver;
private string $shortCode;
public function setUp(): void
{
$shortUrl = (new ShortUrl(''))->setVisits(
new ArrayCollection(map(range(0, 10), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()))),
);
$shortUrl = (new ShortUrl(''))->setVisits(new ArrayCollection(
map(range(0, 10), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())),
));
$this->shortCode = $shortUrl->getShortCode();
$this->em = $this->prophesize(EntityManagerInterface::class);
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneBy(Argument::type('array'))->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->urlResolver->resolveShortUrl(Argument::cetera())->willReturn($shortUrl);
}
/** @test */
@ -51,7 +52,7 @@ class DeleteShortUrlServiceTest extends TestCase
$this->shortCode,
));
$service->deleteByShortCode($this->shortCode);
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
}
/** @test */
@ -62,7 +63,7 @@ class DeleteShortUrlServiceTest extends TestCase
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode($this->shortCode, true);
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode), true);
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
@ -76,7 +77,7 @@ class DeleteShortUrlServiceTest extends TestCase
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode($this->shortCode);
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
@ -90,7 +91,7 @@ class DeleteShortUrlServiceTest extends TestCase
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode($this->shortCode);
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
@ -101,6 +102,6 @@ class DeleteShortUrlServiceTest extends TestCase
return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions([
'visitsThreshold' => $visitsThreshold,
'checkVisitsThreshold' => $checkVisitsThreshold,
]));
]), $this->urlResolver->reveal());
}
}

View File

@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
@ -38,13 +39,13 @@ class ShortUrlResolverTest extends TestCase
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$findOne = $repo->findOne($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToShortUrl($shortCode);
$result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
$findOne->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
@ -54,14 +55,14 @@ class ShortUrlResolverTest extends TestCase
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn(null);
$findOne = $repo->findOne($shortCode, null)->willReturn(null);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$findOneByShortCode->shouldBeCalledOnce();
$findOne->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->shortCodeToShortUrl($shortCode);
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
}
/** @test */
@ -71,10 +72,10 @@ class ShortUrlResolverTest extends TestCase
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode);
$result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode));
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
@ -90,14 +91,14 @@ class ShortUrlResolverTest extends TestCase
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$findOneByShortCode->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode);
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode));
}
public function provideDisabledShortUrls(): iterable

View File

@ -12,9 +12,11 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use function count;
@ -23,13 +25,17 @@ class ShortUrlServiceTest extends TestCase
{
private ShortUrlService $service;
private ObjectProphecy $em;
private ObjectProphecy $urlResolver;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->em->persist(Argument::any())->willReturn(null);
$this->em->flush()->willReturn(null);
$this->service = new ShortUrlService($this->em->reveal());
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->service = new ShortUrlService($this->em->reveal(), $this->urlResolver->reveal());
}
/** @test */
@ -47,40 +53,25 @@ class ShortUrlServiceTest extends TestCase
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$list = $this->service->listShortUrls();
$list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance());
$this->assertEquals(4, $list->getCurrentItemCount());
}
/** @test */
public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode(): void
{
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(null)
->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$this->service->setTagsByShortCode($shortCode);
}
/** @test */
public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void
{
$shortUrl = $this->prophesize(ShortUrl::class);
$shortUrl->setTags(Argument::any())->shouldBeCalledOnce();
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl->reveal())
->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl->reveal())
->shouldBeCalledOnce();
$tagRepo = $this->prophesize(EntityRepository::class);
$tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce();
$tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce();
$this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
$this->service->setTagsByShortCode($shortCode, ['foo', 'bar']);
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']);
}
/** @test */
@ -88,23 +79,22 @@ class ShortUrlServiceTest extends TestCase
{
$shortUrl = new ShortUrl('');
$repo = $this->prophesize(ShortUrlRepository::class);
$findShortUrl = $repo->findOneBy(['shortCode' => 'abc123'])->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl);
$flush = $this->em->flush()->willReturn(null);
$result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::fromRawData([
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
'maxVisits' => 5,
]));
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), ShortUrlMeta::fromRawData(
[
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
'maxVisits' => 5,
],
));
$this->assertSame($shortUrl, $result);
$this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince());
$this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
$this->assertEquals(5, $shortUrl->getMaxVisits());
$findShortUrl->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use Laminas\Stdlib\ArrayUtils;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
@ -16,11 +15,17 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use function Functional\map;
use function range;
class VisitsTrackerTest extends TestCase
{
private VisitsTracker $visitsTracker;
@ -39,14 +44,11 @@ class VisitsTrackerTest extends TestCase
public function trackPersistsVisit(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce();
$this->em->flush()->shouldBeCalledOnce();
$this->visitsTracker->track($shortCode, Visitor::emptyInstance());
$this->visitsTracker->track(new ShortUrl($shortCode), Visitor::emptyInstance());
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
@ -55,10 +57,7 @@ class VisitsTrackerTest extends TestCase
public function trackedIpAddressGetsObfuscated(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->em->persist(Argument::any())->will(function ($args) {
/** @var Visit $visit */
$visit = $args[0];
@ -68,7 +67,7 @@ class VisitsTrackerTest extends TestCase
})->shouldBeCalledOnce();
$this->em->flush()->shouldBeCalledOnce();
$this->visitsTracker->track($shortCode, new Visitor('', '', '4.3.2.1'));
$this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1'));
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
@ -77,22 +76,33 @@ class VisitsTrackerTest extends TestCase
public function infoReturnsVisitsForCertainShortCode(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(EntityRepository::class);
$count = $repo->count(['shortCode' => $shortCode])->willReturn(1);
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(true);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$list = [
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
];
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByShortCode($shortCode, Argument::type(DateRange::class), 1, 0)->willReturn($list);
$repo2->countVisitsByShortCode($shortCode, Argument::type(DateRange::class))->willReturn(1);
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0)->willReturn($list);
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams());
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
$count->shouldHaveBeenCalledOnce();
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(false);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->expectException(ShortUrlNotFoundException::class);
$count->shouldBeCalledOnce();
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
}
}

View File

@ -37,6 +37,7 @@ return [
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ConfigAbstractFactory::class,
],
],
@ -72,6 +73,8 @@ 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\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ['config.url_shortener.domain.hostname'],
],
];

View File

@ -6,29 +6,32 @@ namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
$builder = new ClassMetadataBuilder($metadata);
use function Shlinkio\Shlink\Core\determineTableName;
$builder->setTable('api_keys');
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->createField('id', Types::BIGINT)
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->setTable(determineTableName('api_keys', $emConfig));
$builder->createField('key', Types::STRING)
->columnName('`key`')
->unique()
->build();
$builder->createField('id', Types::BIGINT)
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('expiration_date')
->nullable()
->build();
$builder->createField('key', Types::STRING)
->columnName('`key`')
->unique()
->build();
$builder->createField('enabled', Types::BOOLEAN)
->build();
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('expiration_date')
->nullable()
->build();
$builder->createField('enabled', Types::BOOLEAN)
->build();
};

View File

@ -4,26 +4,25 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
$contentNegotiationMiddleware = [Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class];
$dropDomainMiddleware = [Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class];
return [
'routes' => [
Action\HealthAction::getRouteDef(),
// Short codes
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef(),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef(),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef(),
Action\ShortUrl\CreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
Action\ShortUrl\EditShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef($dropDomainMiddleware),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef(),
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef($dropDomainMiddleware),
// Visits
Action\Visit\GetVisitsAction::getRouteDef(),
Action\Visit\GetVisitsAction::getRouteDef($dropDomainMiddleware),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),

View File

@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -26,8 +27,8 @@ class DeleteShortUrlAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
$this->deleteShortUrlService->deleteByShortCode($shortCode);
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$this->deleteShortUrlService->deleteByShortCode($identifier);
return new EmptyResponse();
}
}

View File

@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -28,9 +29,9 @@ class EditShortUrlAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$postData = (array) $request->getParsedBody();
$shortCode = $request->getAttribute('shortCode', '');
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::fromRawData($postData));
$this->shortUrlService->updateMetadataByShortCode($identifier, ShortUrlMeta::fromRawData($postData));
return new EmptyResponse();
}
}

View File

@ -9,6 +9,7 @@ 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\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -27,7 +28,6 @@ class EditShortUrlTagsAction extends AbstractRestAction
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$bodyParams = $request->getParsedBody();
if (! isset($bodyParams['tags'])) {
@ -35,9 +35,10 @@ class EditShortUrlTagsAction extends AbstractRestAction
'tags' => 'List of tags has to be provided',
]);
}
$tags = $bodyParams['tags'];
['tags' => $tags] = $bodyParams;
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags);
$shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags);
return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
}
}

View File

@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use InvalidArgumentException;
use Laminas\Diactoros\Response\JsonResponse;
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\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -36,38 +34,11 @@ class ListShortUrlsAction extends AbstractRestAction
$this->domainConfig = $domainConfig;
}
/**
* @throws InvalidArgumentException
*/
public function handle(Request $request): Response
{
$params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
$shortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData($request->getQueryParams()));
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig,
))]);
}
/**
* @param array $query
* @return array
*/
private function queryToListParams(array $query): array
{
return [
(int) ($query['page'] ?? 1),
$query['searchTerm'] ?? null,
$query['tags'] ?? [],
$query['orderBy'] ?? null,
$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,
);
}
}

View File

@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -32,11 +33,9 @@ class ResolveShortUrlAction extends AbstractRestAction
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$domain = $request->getQueryParams()['domain'] ?? null;
$transformer = new ShortUrlDataTransformer($this->domainConfig);
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromApiRequest($request));
$url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url));
}
}

View File

@ -9,6 +9,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\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -30,8 +31,8 @@ class GetVisitsAction extends AbstractRestAction
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$visits = $this->visitsTracker->info($identifier, VisitsParams::fromRawData($request->getQueryParams()));
return new JsonResponse([
'visits' => $this->serializePaginator($visits),

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class DropDefaultDomainFromQueryMiddleware implements MiddlewareInterface
{
private string $defaultDomain;
public function __construct(string $defaultDomain)
{
$this->defaultDomain = $defaultDomain;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$query = $request->getQueryParams();
if (isset($query['domain']) && $query['domain'] === $this->defaultDomain) {
unset($query['domain']);
$request = $request->withQueryParams($query);
}
return $handler->handle($request);
}
}

View File

@ -5,15 +5,22 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
class DeleteShortUrlActionTest extends ApiTestCase
{
/** @test */
public function notFoundErrorIsReturnWhenDeletingInvalidUrl(): void
{
$expectedDetail = 'No URL found with short code "invalid"';
use NotFoundUrlHelpersTrait;
$resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/invalid');
/**
* @test
* @dataProvider provideInvalidUrls
*/
public function notFoundErrorIsReturnWhenDeletingInvalidUrl(
string $shortCode,
?string $domain,
string $expectedDetail
): void {
$resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain));
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
@ -21,7 +28,8 @@ class DeleteShortUrlActionTest extends ApiTestCase
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Short URL not found', $payload['title']);
$this->assertEquals('invalid', $payload['shortCode']);
$this->assertEquals($shortCode, $payload['shortCode']);
$this->assertEquals($domain, $payload['domain'] ?? null);
}
/** @test */
@ -42,4 +50,20 @@ class DeleteShortUrlActionTest extends ApiTestCase
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Cannot delete short URL', $payload['title']);
}
/** @test */
public function properShortUrlIsDeletedWhenDomainIsProvided(): void
{
$fetchWithDomainBefore = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com');
$fetchWithoutDomainBefore = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789');
$deleteResp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/ghi789?domain=example.com');
$fetchWithDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com');
$fetchWithoutDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789');
$this->assertEquals(self::STATUS_OK, $fetchWithDomainBefore->getStatusCode());
$this->assertEquals(self::STATUS_OK, $fetchWithoutDomainBefore->getStatusCode());
$this->assertEquals(self::STATUS_NO_CONTENT, $deleteResp->getStatusCode());
$this->assertEquals(self::STATUS_NOT_FOUND, $fetchWithDomainAfter->getStatusCode());
$this->assertEquals(self::STATUS_OK, $fetchWithoutDomainAfter->getStatusCode());
}
}

View File

@ -7,14 +7,17 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use GuzzleHttp\RequestOptions;
use Laminas\Diactoros\Uri;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
use function Functional\first;
use function GuzzleHttp\Psr7\build_query;
use function sprintf;
class EditShortUrlActionTest extends ApiTestCase
{
use ArraySubsetAsserts;
use NotFoundUrlHelpersTrait;
/**
* @test
@ -61,20 +64,24 @@ class EditShortUrlActionTest extends ApiTestCase
private function findShortUrlMetaByShortCode(string $shortCode): ?array
{
// FIXME Call GET /short-urls/{shortCode} once issue https://github.com/shlinkio/shlink/issues/628 is fixed
$allShortUrls = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, '/short-urls'));
$list = $allShortUrls['shortUrls']['data'] ?? [];
$matchingShortUrl = first($list, fn (array $shortUrl) => $shortUrl['shortCode'] ?? '' === $shortCode);
$matchingShortUrl = $this->getJsonResponsePayload(
$this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode),
);
return $matchingShortUrl['meta'] ?? null;
}
/** @test */
public function tryingToEditInvalidUrlReturnsNotFoundError(): void
{
$expectedDetail = 'No URL found with short code "invalid"';
$resp = $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => []]);
/**
* @test
* @dataProvider provideInvalidUrls
*/
public function tryingToEditInvalidUrlReturnsNotFoundError(
string $shortCode,
?string $domain,
string $expectedDetail
): void {
$url = $this->buildShortUrlPath($shortCode, $domain);
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []]);
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
@ -82,7 +89,8 @@ class EditShortUrlActionTest extends ApiTestCase
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Short URL not found', $payload['title']);
$this->assertEquals('invalid', $payload['shortCode']);
$this->assertEquals($shortCode, $payload['shortCode']);
$this->assertEquals($domain, $payload['domain'] ?? null);
}
/** @test */
@ -101,4 +109,37 @@ class EditShortUrlActionTest extends ApiTestCase
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Invalid data', $payload['title']);
}
/**
* @test
* @dataProvider provideDomains
*/
public function metadataIsEditedOnProperShortUrlBasedOnDomain(?string $domain, string $expectedUrl): void
{
$shortCode = 'ghi789';
$url = new Uri(sprintf('/short-urls/%s', $shortCode));
if ($domain !== null) {
$url = $url->withQuery(build_query(['domain' => $domain]));
}
$editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [
'maxVisits' => 100,
]]);
$editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url));
$this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
$this->assertEquals($domain, $editedShortUrl['domain']);
$this->assertEquals($expectedUrl, $editedShortUrl['longUrl']);
$this->assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null);
}
public function provideDomains(): iterable
{
yield 'domain' => [
'example.com',
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
];
yield 'no domain' => [null, 'https://shlink.io/documentation/'];
}
}

View File

@ -6,9 +6,12 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
class EditShortUrlTagsActionTest extends ApiTestCase
{
use NotFoundUrlHelpersTrait;
/** @test */
public function notProvidingTagsReturnsBadRequest(): void
{
@ -24,12 +27,17 @@ class EditShortUrlTagsActionTest extends ApiTestCase
$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 => [
/**
* @test
* @dataProvider provideInvalidUrls
*/
public function providingInvalidShortCodeReturnsBadRequest(
string $shortCode,
?string $domain,
string $expectedDetail
): void {
$url = $this->buildShortUrlPath($shortCode, $domain, '/tags');
$resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [
'tags' => ['foo', 'bar'],
]]);
$payload = $this->getJsonResponsePayload($resp);
@ -39,6 +47,28 @@ class EditShortUrlTagsActionTest extends ApiTestCase
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Short URL not found', $payload['title']);
$this->assertEquals('invalid', $payload['shortCode']);
$this->assertEquals($shortCode, $payload['shortCode']);
$this->assertEquals($domain, $payload['domain'] ?? null);
}
/** @test */
public function tagsAreSetOnProperShortUrlBasedOnProvidedDomain(): void
{
$urlWithoutDomain = '/short-urls/ghi789/tags';
$urlWithDomain = $urlWithoutDomain . '?domain=example.com';
$setTagsWithDomain = $this->callApiWithKey(self::METHOD_PUT, $urlWithDomain, [RequestOptions::JSON => [
'tags' => ['foo', 'bar'],
]]);
$fetchWithoutDomain = $this->getJsonResponsePayload(
$this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789'),
);
$fetchWithDomain = $this->getJsonResponsePayload(
$this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com'),
);
$this->assertEquals(self::STATUS_OK, $setTagsWithDomain->getStatusCode());
$this->assertEquals([], $fetchWithoutDomain['tags']);
$this->assertEquals(['bar', 'foo'], $fetchWithDomain['tags']);
}
}

View File

@ -4,16 +4,27 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Laminas\Diactoros\Uri;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
use function GuzzleHttp\Psr7\build_query;
use function sprintf;
class GetVisitsActionTest extends ApiTestCase
{
/** @test */
public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError(): void
{
$expectedDetail = 'No URL found with short code "invalid"';
use NotFoundUrlHelpersTrait;
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid/visits');
/**
* @test
* @dataProvider provideInvalidUrls
*/
public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError(
string $shortCode,
?string $domain,
string $expectedDetail
): void {
$resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain, '/visits'));
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
@ -21,6 +32,33 @@ class GetVisitsActionTest extends ApiTestCase
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Short URL not found', $payload['title']);
$this->assertEquals('invalid', $payload['shortCode']);
$this->assertEquals($shortCode, $payload['shortCode']);
$this->assertEquals($domain, $payload['domain'] ?? null);
}
/**
* @test
* @dataProvider provideDomains
*/
public function properVisitsAreReturnedWhenDomainIsProvided(?string $domain, int $expectedAmountOfVisits): void
{
$shortCode = 'ghi789';
$url = new Uri(sprintf('/short-urls/%s/visits', $shortCode));
if ($domain !== null) {
$url = $url->withQuery(build_query(['domain' => $domain]));
}
$resp = $this->callApiWithKey(self::METHOD_GET, (string) $url);
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1);
$this->assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []);
}
public function provideDomains(): iterable
{
yield 'domain' => ['example.com', 0];
yield 'no domain' => [null, 2];
}
}

View File

@ -24,6 +24,21 @@ class ListShortUrlsTest extends ApiTestCase
'validUntil' => null,
'maxVisits' => null,
],
'domain' => null,
];
private const SHORT_URL_DOCS = [
'shortCode' => 'ghi789',
'shortUrl' => 'http://doma.in/ghi789',
'longUrl' => 'https://shlink.io/documentation/',
'dateCreated' => '2018-05-01T00:00:00+00:00',
'visitsCount' => 2,
'tags' => [],
'meta' => [
'validSince' => null,
'validUntil' => null,
'maxVisits' => null,
],
'domain' => null,
];
private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [
'shortCode' => 'custom-with-domain',
@ -37,6 +52,7 @@ class ListShortUrlsTest extends ApiTestCase
'validUntil' => null,
'maxVisits' => null,
],
'domain' => 'some-domain.com',
];
private const SHORT_URL_META = [
'shortCode' => 'def456',
@ -52,6 +68,7 @@ class ListShortUrlsTest extends ApiTestCase
'validUntil' => null,
'maxVisits' => null,
],
'domain' => null,
];
private const SHORT_URL_CUSTOM_SLUG = [
'shortCode' => 'custom',
@ -65,6 +82,7 @@ class ListShortUrlsTest extends ApiTestCase
'validUntil' => null,
'maxVisits' => 2,
],
'domain' => null,
];
private const SHORT_URL_CUSTOM_DOMAIN = [
'shortCode' => 'ghi789',
@ -80,6 +98,7 @@ class ListShortUrlsTest extends ApiTestCase
'validUntil' => null,
'maxVisits' => null,
],
'domain' => 'example.com',
];
/**
@ -104,6 +123,7 @@ class ListShortUrlsTest extends ApiTestCase
{
yield [[], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG,
@ -114,8 +134,17 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_META,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_DOMAIN,
]];
yield [['orderBy' => ['shortCode' => 'DESC']], [
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_DOMAIN,
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_SHLINK,
]];
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG,
@ -123,6 +152,7 @@ class ListShortUrlsTest extends ApiTestCase
]];
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_SHLINK,
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
]];
yield [['tags' => ['foo']], [
@ -139,6 +169,9 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_DOMAIN,
]];
yield [['searchTerm' => 'example.com'], [
self::SHORT_URL_CUSTOM_DOMAIN,
]];
}
private function buildPagination(int $itemsCount): array

View File

@ -7,11 +7,14 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
use function sprintf;
class ResolveShortUrlActionTest extends ApiTestCase
{
use NotFoundUrlHelpersTrait;
/**
* @test
* @dataProvider provideDisabledMeta
@ -40,12 +43,16 @@ class ResolveShortUrlActionTest extends ApiTestCase
yield 'maxVisits reached' => [['maxVisits' => 1]];
}
/** @test */
public function tryingToResolveInvalidUrlReturnsNotFoundError(): void
{
$expectedDetail = 'No URL found with short code "invalid"';
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid');
/**
* @test
* @dataProvider provideInvalidUrls
*/
public function tryingToResolveInvalidUrlReturnsNotFoundError(
string $shortCode,
?string $domain,
string $expectedDetail
): void {
$resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain));
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
@ -53,6 +60,7 @@ class ResolveShortUrlActionTest extends ApiTestCase
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
$this->assertEquals($expectedDetail, $payload['detail']);
$this->assertEquals('Short URL not found', $payload['title']);
$this->assertEquals('invalid', $payload['shortCode']);
$this->assertEquals($shortCode, $payload['shortCode']);
$this->assertEquals($domain, $payload['domain'] ?? null);
}
}

View File

@ -37,11 +37,17 @@ class ShortUrlsFixture extends AbstractFixture
), '2019-01-01 00:00:20');
$manager->persist($customShortUrl);
$withDomainShortUrl = $this->setShortUrlDate(new ShortUrl(
$ghiShortUrl = $this->setShortUrlDate(
new ShortUrl('https://shlink.io/documentation/', ShortUrlMeta::fromRawData(['customSlug' => 'ghi789'])),
'2018-05-01',
);
$manager->persist($ghiShortUrl);
$withDomainDuplicatingShortCode = $this->setShortUrlDate(new ShortUrl(
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']),
), '2019-01-01 00:00:30');
$manager->persist($withDomainShortUrl);
$manager->persist($withDomainDuplicatingShortCode);
$withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://google.com',
@ -53,6 +59,7 @@ class ShortUrlsFixture extends AbstractFixture
$this->addReference('abc123_short_url', $abcShortUrl);
$this->addReference('def456_short_url', $defShortUrl);
$this->addReference('ghi789_short_url', $ghiShortUrl);
}
private function setShortUrlDate(ShortUrl $shortUrl, string $date): ShortUrl

View File

@ -31,6 +31,11 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1')));
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
/** @var ShortUrl $defShortUrl */
$defShortUrl = $this->getReference('ghi789_short_url');
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4')));
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
$manager->flush();
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Utils;
use Laminas\Diactoros\Uri;
use function GuzzleHttp\Psr7\build_query;
use function sprintf;
trait NotFoundUrlHelpersTrait
{
public function provideInvalidUrls(): iterable
{
yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"'];
yield 'invalid shortcode without domain' => [
'abc123',
'example.com',
'No URL found with short code "abc123" for domain "example.com"',
];
yield 'invalid shortcode + domain' => [
'custom-with-domain',
'example.com',
'No URL found with short code "custom-with-domain" for domain "example.com"',
];
}
public function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string
{
$url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix));
if ($domain !== null) {
$url = $url->withQuery(build_query(['domain' => $domain]));
}
return (string) $url;
}
}

View File

@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction;
@ -34,8 +35,8 @@ class EditShortUrlTagsActionTest extends TestCase
public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void
{
$shortCode = 'abc123';
$this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$this->shortUrlService->setTagsByShortCode(new ShortUrlIdentifier($shortCode), [])->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$response = $this->action->handle(
(new ServerRequest())->withAttribute('shortCode', 'abc123')

View File

@ -12,7 +12,7 @@ use Laminas\Paginator\Paginator;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction;
@ -43,15 +43,17 @@ class ListShortUrlsActionTest extends TestCase
?string $expectedSearchTerm,
array $expectedTags,
?string $expectedOrderBy,
DateRange $expectedDateRange
?string $startDate = null,
?string $endDate = null
): void {
$listShortUrls = $this->service->listShortUrls(
$expectedPage,
$expectedSearchTerm,
$expectedTags,
$expectedOrderBy,
$expectedDateRange,
)->willReturn(new Paginator(new ArrayAdapter()));
$listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([
'page' => $expectedPage,
'searchTerm' => $expectedSearchTerm,
'tags' => $expectedTags,
'orderBy' => $expectedOrderBy,
'startDate' => $startDate,
'endDate' => $endDate,
]))->willReturn(new Paginator(new ArrayAdapter()));
/** @var JsonResponse $response */
$response = $this->action->handle((new ServerRequest())->withQueryParams($query));
@ -66,25 +68,25 @@ class ListShortUrlsActionTest extends TestCase
public function provideFilteringData(): iterable
{
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 [[], 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 [[
'page' => '2',
'orderBy' => $orderBy = 'something',
'tags' => $tags = ['one', 'two'],
], 2, null, $tags, $orderBy, new DateRange()];
], 2, null, $tags, $orderBy];
yield [
['startDate' => $date = Chronos::now()->toAtomString()],
1,
null,
[],
null,
new DateRange(Chronos::parse($date)),
$date,
];
yield [
['endDate' => $date = Chronos::now()->toAtomString()],
@ -92,7 +94,8 @@ class ListShortUrlsActionTest extends TestCase
null,
[],
null,
new DateRange(null, Chronos::parse($date)),
null,
$date,
];
yield [
[
@ -103,7 +106,8 @@ class ListShortUrlsActionTest extends TestCase
null,
[],
null,
new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)),
$startDate,
$endDate,
];
}
}

View File

@ -8,6 +8,7 @@ use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction;
@ -28,7 +29,7 @@ class ResolveShortUrlActionTest extends TestCase
public function correctShortCodeReturnsSuccess(): void
{
$shortCode = 'abc123';
$this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn(
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn(
new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce();

View File

@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction;
@ -31,7 +32,7 @@ class GetVisitsActionTest extends TestCase
public function providingCorrectShortCodeReturnsVisits(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willReturn(
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::type(VisitsParams::class))->willReturn(
new Paginator(new ArrayAdapter([])),
)->shouldBeCalledOnce();
@ -43,7 +44,7 @@ class GetVisitsActionTest extends TestCase
public function paramsAreReadFromQuery(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new VisitsParams(
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(
new DateRange(null, Chronos::parse('2016-01-01 00:00:00')),
3,
10,

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware;
class DropDefaultDomainFromQueryMiddlewareTest extends TestCase
{
private DropDefaultDomainFromQueryMiddleware $middleware;
private ObjectProphecy $next;
public function setUp(): void
{
$this->next = $this->prophesize(RequestHandlerInterface::class);
$this->middleware = new DropDefaultDomainFromQueryMiddleware('doma.in');
}
/**
* @test
* @dataProvider provideQueryParams
*/
public function domainIsDroppedWhenDefaultOneIsProvided(array $providedQuery, array $expectedQuery): void
{
$req = ServerRequestFactory::fromGlobals()->withQueryParams($providedQuery);
$handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedQuery) {
Assert::assertEquals($expectedQuery, $request->getQueryParams());
return $request;
}))->willReturn(new Response());
$this->middleware->process($req, $this->next->reveal());
$handle->shouldHaveBeenCalledOnce();
}
public function provideQueryParams(): iterable
{
yield [[], []];
yield [['foo' => 'bar'], ['foo' => 'bar']];
yield [['foo' => 'bar', 'domain' => 'doma.in'], ['foo' => 'bar']];
yield [['foo' => 'bar', 'domain' => 'not_default'], ['foo' => 'bar', 'domain' => 'not_default']];
yield [['domain' => 'doma.in'], []];
}
}

View File

@ -2,6 +2,5 @@ parameters:
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
ignoreErrors:
- '#Undefined variable: \$metadata#'
- '#AbstractQuery::setParameters()#'
- '#mustRun()#'