Compare commits

...

16 Commits

Author SHA1 Message Date
Alejandro Celaya
eca7800487 Merge pull request #632 from shlinkio/develop
Release v2.0.3
2020-01-27 11:44:43 +01:00
Alejandro Celaya
b9e58b9300 Merge pull request #631 from acelaya-forks/feature/permission-denied
Feature/permission denied
2020-01-27 11:37:37 +01:00
Alejandro Celaya
54918db9ef Updated changelog 2020-01-27 11:31:44 +01:00
Alejandro Celaya
b07a603456 Updated dependencies 2020-01-27 11:30:29 +01:00
Alejandro Celaya
4fb2c64fa8 Merge pull request #630 from acelaya-forks/feature/fetch-not-visitable-url
Feature/fetch not visitable url
2020-01-26 20:00:47 +01:00
Alejandro Celaya
258c4102be Updated changelog 2020-01-26 19:55:03 +01:00
Alejandro Celaya
b9c7f8e8d4 Added unit tests for ShortUrlresolver 2020-01-26 19:53:18 +01:00
Alejandro Celaya
f32e7cc7c4 Removed tests checking domain logic from ShortUrlRepositoryTest 2020-01-26 19:25:41 +01:00
Alejandro Celaya
4ebd48b2b0 Created new service to resolve short URLs 2020-01-26 19:21:51 +01:00
Alejandro Celaya
f71bd84a20 Merge pull request #629 from acelaya-forks/feature/reset-meta
Feature/reset meta
2020-01-26 09:49:36 +01:00
Alejandro Celaya
33b45eb620 Updated changelog 2020-01-26 09:37:43 +01:00
Alejandro Celaya
1f9a912c04 Added API tests covering the edition of short URL meta with resetted values 2020-01-26 09:29:04 +01:00
Alejandro Celaya
45151cdde6 Standardized how the ShortUrlMeta object is created by exposing a single named constructor 2020-01-26 08:42:51 +01:00
Alejandro Celaya
8ca45eb388 Merge pull request #627 from acelaya-forks/feature/remote-ip-order
Feature/remote ip order
2020-01-24 21:28:39 +01:00
Alejandro Celaya
b7a34a6640 Updated changelog 2020-01-24 21:21:13 +01:00
Alejandro Celaya
8ec686f4e2 Updated order in which headers for remote IP detection are inspected 2020-01-24 21:19:40 +01:00
38 changed files with 491 additions and 257 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). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.0.3 - 2020-01-27
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected.
* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset.
* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled.
* [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once.
## 2.0.2 - 2020-01-12 ## 2.0.2 - 2020-01-12
#### Added #### Added

View File

@@ -49,8 +49,8 @@
"pugx/shortid-php": "^0.5", "pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.5", "shlinkio/shlink-common": "^2.5",
"shlinkio/shlink-event-dispatcher": "^1.3", "shlinkio/shlink-event-dispatcher": "^1.3",
"shlinkio/shlink-installer": "^4.0", "shlinkio/shlink-installer": "^4.0.1",
"shlinkio/shlink-ip-geolocation": "^1.3", "shlinkio/shlink-ip-geolocation": "^1.3.1",
"symfony/console": "^5.0", "symfony/console": "^5.0",
"symfony/filesystem": "^5.0", "symfony/filesystem": "^5.0",
"symfony/lock": "^5.0", "symfony/lock": "^5.0",
@@ -58,6 +58,7 @@
}, },
"require-dev": { "require-dev": {
"devster/ubench": "^2.0", "devster/ubench": "^2.0",
"dms/phpunit-arraysubset-asserts": "^0.1.0",
"eaglewu/swoole-ide-helper": "dev-master", "eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.15.0", "infection/infection": "^0.15.0",
"phpstan/phpstan": "^0.12.3", "phpstan/phpstan": "^0.12.3",

View File

@@ -7,11 +7,11 @@ return [
'ip_address_resolution' => [ 'ip_address_resolution' => [
'headers_to_inspect' => [ 'headers_to_inspect' => [
'CF-Connecting-IP', 'CF-Connecting-IP',
'True-Client-IP',
'X-Real-IP',
'Forwarded',
'X-Forwarded-For', 'X-Forwarded-For',
'X-Forwarded', 'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip', 'X-Cluster-Client-Ip',
'Client-Ip', 'Client-Ip',
], ],

View File

@@ -7,9 +7,7 @@ return [
'geolite2' => [ 'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => sys_get_temp_dir(), 'temp_dir' => sys_get_temp_dir(),
'download_from' => 'license_key' => 'G4Lm0C60yJsnkdPi',
'https://download.maxmind.com/app/geoip_download'
. '?edition_id=GeoLite2-City&license_key=G4Lm0C60yJsnkdPi&suffix=tar.gz',
], ],
]; ];

View File

@@ -1,6 +1,6 @@
# Shlink Docker image # Shlink Docker image
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime. This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.

View File

@@ -55,7 +55,7 @@ return [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'], GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'], Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class], Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],

View File

@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -121,14 +122,14 @@ class GenerateShortUrlCommand extends Command
$shortUrl = $this->urlShortener->urlToShortCode( $shortUrl = $this->urlShortener->urlToShortCode(
new Uri($longUrl), new Uri($longUrl),
$tags, $tags,
ShortUrlMeta::createFromParams( ShortUrlMeta::fromRawData([
$input->getOption('validSince'), ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
$input->getOption('validUntil'), ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
$customSlug, ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
$maxVisits !== null ? (int) $maxVisits : null, ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
$input->getOption('findIfExists'), ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
$input->getOption('domain'), ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
), ]),
); );
$io->writeln([ $io->writeln([

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -20,12 +20,12 @@ class ResolveUrlCommand extends Command
{ {
public const NAME = 'short-url:parse'; public const NAME = 'short-url:parse';
private UrlShortenerInterface $urlShortener; private ShortUrlResolverInterface $urlResolver;
public function __construct(UrlShortenerInterface $urlShortener) public function __construct(ShortUrlResolverInterface $urlResolver)
{ {
parent::__construct(); parent::__construct();
$this->urlShortener = $urlShortener; $this->urlResolver = $urlResolver;
} }
protected function configure(): void protected function configure(): void
@@ -58,7 +58,7 @@ class ResolveUrlCommand extends Command
$domain = $input->getOption('domain'); $domain = $input->getOption('domain');
try { try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); $url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl())); $output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS; return ExitCodes::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) { } catch (ShortUrlNotFoundException $e) {

View File

@@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@@ -20,12 +20,12 @@ use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase class ResolveUrlCommandTest extends TestCase
{ {
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlResolver;
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$command = new ResolveUrlCommand($this->urlShortener->reveal()); $command = new ResolveUrlCommand($this->urlResolver->reveal());
$app = new Application(); $app = new Application();
$app->add($command); $app->add($command);
@@ -38,8 +38,8 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123'; $shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar'; $expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl); $shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn($shortUrl) $this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn($shortUrl)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]); $this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
@@ -50,7 +50,7 @@ class ResolveUrlCommandTest extends TestCase
public function incorrectShortCodeOutputsErrorMessage(): void public function incorrectShortCodeOutputsErrorMessage(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null) $this->urlResolver->shortCodeToShortUrl($shortCode, null)
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode)) ->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
->shouldBeCalledOnce(); ->shouldBeCalledOnce();

View File

@@ -30,6 +30,7 @@ return [
Service\VisitService::class => ConfigAbstractFactory::class, Service\VisitService::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class, Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
Util\UrlValidator::class => ConfigAbstractFactory::class, Util\UrlValidator::class => ConfigAbstractFactory::class,
@@ -56,22 +57,27 @@ return [
Service\VisitService::class => ['em'], Service\VisitService::class => ['em'],
Service\Tag\TagService::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 => ['em'],
Util\UrlValidator::class => ['httpClient'], Util\UrlValidator::class => ['httpClient'],
Action\RedirectAction::class => [ Action\RedirectAction::class => [
Service\UrlShortener::class, Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class, Service\VisitsTracker::class,
Options\AppOptions::class, Options\AppOptions::class,
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\PixelAction::class => [ Action\PixelAction::class => [
Service\UrlShortener::class, Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class, Service\VisitsTracker::class,
Options\AppOptions::class, Options\AppOptions::class,
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'], Action\QrCodeAction::class => [
RouterInterface::class,
Service\ShortUrl\ShortUrlResolver::class,
'Logger_Shlink',
],
Middleware\QrCodeCacheMiddleware::class => [Cache::class], Middleware\QrCodeCacheMiddleware::class => [Cache::class],
], ],

View File

@@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function array_key_exists; use function array_key_exists;
@@ -25,18 +25,18 @@ use function GuzzleHttp\Psr7\parse_query;
abstract class AbstractTrackingAction implements MiddlewareInterface abstract class AbstractTrackingAction implements MiddlewareInterface
{ {
private UrlShortenerInterface $urlShortener; private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker; private VisitsTrackerInterface $visitTracker;
private AppOptions $appOptions; private AppOptions $appOptions;
private LoggerInterface $logger; private LoggerInterface $logger;
public function __construct( public function __construct(
UrlShortenerInterface $urlShortener, ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker, VisitsTrackerInterface $visitTracker,
AppOptions $appOptions, AppOptions $appOptions,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
) { ) {
$this->urlShortener = $urlShortener; $this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker; $this->visitTracker = $visitTracker;
$this->appOptions = $appOptions; $this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger(); $this->logger = $logger ?: new NullLogger();
@@ -50,7 +50,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
$disableTrackParam = $this->appOptions->getDisableTrackParam(); $disableTrackParam = $this->appOptions->getDisableTrackParam();
try { try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); $url = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
// Track visit to this short code // Track visit to this short code
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) { if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action; namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode; use Endroid\QrCode\QrCode;
use Mezzio\Router\Exception\RuntimeException;
use Mezzio\Router\RouterInterface; use Mezzio\Router\RouterInterface;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
@@ -15,7 +14,7 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeAction implements MiddlewareInterface class QrCodeAction implements MiddlewareInterface
{ {
@@ -24,27 +23,19 @@ class QrCodeAction implements MiddlewareInterface
private const MAX_SIZE = 1000; private const MAX_SIZE = 1000;
private RouterInterface $router; private RouterInterface $router;
private UrlShortenerInterface $urlShortener; private ShortUrlResolverInterface $urlResolver;
private LoggerInterface $logger; private LoggerInterface $logger;
public function __construct( public function __construct(
RouterInterface $router, RouterInterface $router,
UrlShortenerInterface $urlShortener, ShortUrlResolverInterface $urlResolver,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
) { ) {
$this->router = $router; $this->router = $router;
$this->urlShortener = $urlShortener; $this->urlResolver = $urlResolver;
$this->logger = $logger ?: new NullLogger(); $this->logger = $logger ?: new NullLogger();
} }
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
* @throws \InvalidArgumentException
* @throws RuntimeException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response public function process(Request $request, RequestHandlerInterface $handler): Response
{ {
// Make sure the short URL exists for this short code // Make sure the short URL exists for this short code
@@ -52,7 +43,7 @@ class QrCodeAction implements MiddlewareInterface
$domain = $request->getUri()->getAuthority(); $domain = $request->getUri()->getAuthority();
try { try {
$this->urlShortener->shortCodeToUrl($shortCode, $domain); $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
} catch (ShortUrlNotFoundException $e) { } catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]); $this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request); return $handler->handle($request);

View File

@@ -135,7 +135,6 @@ class ShortUrl extends AbstractEntity
/** /**
* @param Collection|Visit[] $visits * @param Collection|Visit[] $visits
* @return ShortUrl
* @internal * @internal
*/ */
public function setVisits(Collection $visits): self public function setVisits(Collection $visits): self
@@ -149,9 +148,25 @@ class ShortUrl extends AbstractEntity
return $this->maxVisits; return $this->maxVisits;
} }
public function maxVisitsReached(): bool public function isEnabled(): bool
{ {
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits; $maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
if ($maxVisitsReached) {
return false;
}
$now = Chronos::now();
$beforeValidSince = $this->validSince !== null && $this->validSince->gt($now);
if ($beforeValidSince) {
return false;
}
$afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now);
if ($afterValidUntil) {
return false;
}
return true;
} }
public function toString(array $domainConfig): string public function toString(array $domainConfig): string
@@ -186,12 +201,10 @@ class ShortUrl extends AbstractEntity
} }
$shortUrlTags = invoke($this->getTags(), '__toString'); $shortUrlTags = invoke($this->getTags(), '__toString');
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce( return count($shortUrlTags) === count($tags) && array_reduce(
$tags, $tags,
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag), fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
true, true,
); );
return $hasAllTags;
} }
} }

View File

@@ -9,11 +9,16 @@ use DateTimeInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function array_key_exists;
final class ShortUrlMeta final class ShortUrlMeta
{ {
private bool $validSincePropWasProvided = false;
private ?Chronos $validSince = null; private ?Chronos $validSince = null;
private bool $validUntilPropWasProvided = false;
private ?Chronos $validUntil = null; private ?Chronos $validUntil = null;
private ?string $customSlug = null; private ?string $customSlug = null;
private bool $maxVisitsPropWasProvided = false;
private ?int $maxVisits = null; private ?int $maxVisits = null;
private ?bool $findIfExists = null; private ?bool $findIfExists = null;
private ?string $domain = null; private ?string $domain = null;
@@ -32,44 +37,13 @@ final class ShortUrlMeta
* @param array $data * @param array $data
* @throws ValidationException * @throws ValidationException
*/ */
public static function createFromRawData(array $data): self public static function fromRawData(array $data): self
{ {
$instance = new self(); $instance = new self();
$instance->validate($data); $instance->validate($data);
return $instance; return $instance;
} }
/**
* @param string|Chronos|null $validSince
* @param string|Chronos|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @param bool|null $findIfExists
* @param string|null $domain
* @throws ValidationException
*/
public static function createFromParams( // phpcs:ignore
$validSince = null,
$validUntil = null,
$customSlug = null,
$maxVisits = null,
$findIfExists = null,
$domain = null
): self {
// We do not type hint the arguments because that will be done by the validation process and we would get a
// type error if any of them do not match
$instance = new self();
$instance->validate([
ShortUrlMetaInputFilter::VALID_SINCE => $validSince,
ShortUrlMetaInputFilter::VALID_UNTIL => $validUntil,
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits,
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $findIfExists,
ShortUrlMetaInputFilter::DOMAIN => $domain,
]);
return $instance;
}
/** /**
* @param array $data * @param array $data
* @throws ValidationException * @throws ValidationException
@@ -82,10 +56,13 @@ final class ShortUrlMeta
} }
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); $this->validSince = $this->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 = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS); $maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null; $this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS); $this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN); $this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
} }
@@ -113,7 +90,7 @@ final class ShortUrlMeta
public function hasValidSince(): bool public function hasValidSince(): bool
{ {
return $this->validSince !== null; return $this->validSincePropWasProvided;
} }
public function getValidUntil(): ?Chronos public function getValidUntil(): ?Chronos
@@ -123,7 +100,7 @@ final class ShortUrlMeta
public function hasValidUntil(): bool public function hasValidUntil(): bool
{ {
return $this->validUntil !== null; return $this->validUntilPropWasProvided;
} }
public function getCustomSlug(): ?string public function getCustomSlug(): ?string
@@ -143,7 +120,7 @@ final class ShortUrlMeta
public function hasMaxVisits(): bool public function hasMaxVisits(): bool
{ {
return $this->maxVisits !== null; return $this->maxVisitsPropWasProvided;
} }
public function findIfExists(): bool public function findIfExists(): bool

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository; namespace Shlinkio\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
@@ -146,8 +145,6 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
LEFT JOIN s.domain AS d LEFT JOIN s.domain AS d
WHERE s.shortCode = :shortCode WHERE s.shortCode = :shortCode
AND (s.validSince <= :now OR s.validSince IS NULL)
AND (s.validUntil >= :now OR s.validUntil IS NULL)
AND (s.domain IS NULL OR d.authority = :domain) AND (s.domain IS NULL OR d.authority = :domain)
ORDER BY s.domain {$ordering} ORDER BY s.domain {$ordering}
DQL; DQL;
@@ -156,7 +153,6 @@ DQL;
$query->setMaxResults(1) $query->setMaxResults(1)
->setParameters([ ->setParameters([
'shortCode' => $shortCode, 'shortCode' => $shortCode,
'now' => Chronos::now(),
'domain' => $domain, 'domain' => $domain,
]); ]);
@@ -166,9 +162,7 @@ DQL;
// * The short URL matching the short code but without any domain, or // * The short URL matching the short code but without any domain, or
// * No short URL at all // * No short URL at all
/** @var ShortUrl|null $shortUrl */ return $query->getOneOrNullResult();
$shortUrl = $query->getOneOrNullResult();
return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
} }
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool public function shortCodeIsInUse(string $slug, ?string $domain = null): bool

View File

@@ -0,0 +1,48 @@
<?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;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortUrlResolver implements ShortUrlResolverInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl
{
$shortUrl = $this->shortCodeToShortUrl($shortCode, $domain);
if (! $shortUrl->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
interface ShortUrlResolverInterface
{
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
}

View File

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@@ -124,20 +123,4 @@ class UrlShortener implements UrlShortenerInterface
$this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated); $this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated);
} }
} }
/**
* @throws ShortUrlNotFoundException
* @fixme Move this method to a different service
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
} }

View File

@@ -8,7 +8,6 @@ use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
interface UrlShortenerInterface interface UrlShortenerInterface
@@ -19,9 +18,4 @@ interface UrlShortenerInterface
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl; public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl;
} }

View File

@@ -38,41 +38,17 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function findOneByShortCodeReturnsProperData(): void public function findOneByShortCodeReturnsProperData(): void
{ {
$regularOne = new ShortUrl('foo', ShortUrlMeta::createFromParams(null, null, 'foo')); $regularOne = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
$this->getEntityManager()->persist($regularOne); $this->getEntityManager()->persist($regularOne);
$notYetValid = new ShortUrl( $withDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(
'bar', ['domain' => 'example.com', 'customSlug' => 'domain-short-code'],
ShortUrlMeta::createFromParams(Chronos::now()->addMonth(), null, 'bar_very_long_text'), ));
);
$this->getEntityManager()->persist($notYetValid);
$expired = new ShortUrl('expired', ShortUrlMeta::createFromParams(null, Chronos::now()->subMonth(), 'expired'));
$this->getEntityManager()->persist($expired);
$allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::createFromRawData([
'maxVisits' => 3,
'customSlug' => 'baz',
]));
$visits = [];
for ($i = 0; $i < 3; $i++) {
$visit = new Visit($allVisitsComplete, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit);
$visits[] = $visit;
}
$allVisitsComplete->setVisits(new ArrayCollection($visits));
$this->getEntityManager()->persist($allVisitsComplete);
$withDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData([
'domain' => 'example.com',
'customSlug' => 'domain-short-code',
]));
$this->getEntityManager()->persist($withDomain); $this->getEntityManager()->persist($withDomain);
$withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::createFromRawData([ $withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::fromRawData(
'domain' => 'doma.in', ['domain' => 'doma.in', 'customSlug' => 'foo'],
'customSlug' => 'foo', ));
]));
$this->getEntityManager()->persist($withDomainDuplicatingRegular); $this->getEntityManager()->persist($withDomainDuplicatingRegular);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@@ -91,9 +67,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertNull($this->repo->findOneByShortCode('invalid')); $this->assertNull($this->repo->findOneByShortCode('invalid'));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode())); $this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com')); $this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com'));
$this->assertNull($this->repo->findOneByShortCode($notYetValid->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($expired->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($allVisitsComplete->getShortCode()));
} }
/** @test */ /** @test */
@@ -187,12 +160,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void
{ {
$shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData(['customSlug' => 'my-cool-slug'])); $shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug']));
$this->getEntityManager()->persist($shortUrlWithoutDomain); $this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = new ShortUrl( $shortUrlWithDomain = new ShortUrl(
'foo', 'foo',
ShortUrlMeta::createFromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']), ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']),
); );
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);

View File

@@ -13,22 +13,22 @@ use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Action\PixelAction; use Shlinkio\Shlink\Core\Action\PixelAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
class PixelActionTest extends TestCase class PixelActionTest extends TestCase
{ {
private PixelAction $action; private PixelAction $action;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker; private ObjectProphecy $visitTracker;
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->visitTracker = $this->prophesize(VisitsTracker::class); $this->visitTracker = $this->prophesize(VisitsTracker::class);
$this->action = new PixelAction( $this->action = new PixelAction(
$this->urlShortener->reveal(), $this->urlResolver->reveal(),
$this->visitTracker->reveal(), $this->visitTracker->reveal(),
new AppOptions(), new AppOptions(),
); );
@@ -38,7 +38,7 @@ class PixelActionTest extends TestCase
public function imageIsReturned(): void public function imageIsReturned(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn( $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(
new ShortUrl('http://domain.com/foo/bar'), new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce(); )->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce(); $this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();

View File

@@ -15,29 +15,29 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeActionTest extends TestCase class QrCodeActionTest extends TestCase
{ {
private QrCodeAction $action; private QrCodeAction $action;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlResolver;
public function setUp(): void public function setUp(): void
{ {
$router = $this->prophesize(RouterInterface::class); $router = $this->prophesize(RouterInterface::class);
$router->generateUri(Argument::cetera())->willReturn('/foo/bar'); $router->generateUri(Argument::cetera())->willReturn('/foo/bar');
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->action = new QrCodeAction($router->reveal(), $this->urlShortener->reveal()); $this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
} }
/** @test */ /** @test */
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class); $delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response()); $process = $delegate->handle(Argument::any())->willReturn(new Response());
@@ -50,8 +50,8 @@ class QrCodeActionTest extends TestCase
public function anInvalidShortCodeWillReturnNotFoundResponse(): void public function anInvalidShortCodeWillReturnNotFoundResponse(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class); $delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response()); $process = $delegate->handle(Argument::any())->willReturn(new Response());
@@ -64,8 +64,8 @@ class QrCodeActionTest extends TestCase
public function aCorrectRequestReturnsTheQrCodeResponse(): void public function aCorrectRequestReturnsTheQrCodeResponse(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(new ShortUrl('')) $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(new ShortUrl(''))
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class); $delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process( $resp = $this->action->process(

View File

@@ -14,24 +14,24 @@ use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function array_key_exists; use function array_key_exists;
class RedirectActionTest extends TestCase class RedirectActionTest extends TestCase
{ {
private RedirectAction $action; private RedirectAction $action;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker; private ObjectProphecy $visitTracker;
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->visitTracker = $this->prophesize(VisitsTracker::class); $this->visitTracker = $this->prophesize(VisitsTrackerInterface::class);
$this->action = new RedirectAction( $this->action = new RedirectAction(
$this->urlShortener->reveal(), $this->urlResolver->reveal(),
$this->visitTracker->reveal(), $this->visitTracker->reveal(),
new Options\AppOptions(['disableTrackParam' => 'foobar']), new Options\AppOptions(['disableTrackParam' => 'foobar']),
); );
@@ -45,7 +45,7 @@ class RedirectActionTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing'); $shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl); $shortCodeToUrl = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void { $track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
}); });
@@ -74,8 +74,8 @@ class RedirectActionTest extends TestCase
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class) $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled(); $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$handler = $this->prophesize(RequestHandlerInterface::class); $handler = $this->prophesize(RequestHandlerInterface::class);

View File

@@ -28,7 +28,7 @@ class ShortUrlTest extends TestCase
public function provideInvalidShortUrls(): iterable public function provideInvalidShortUrls(): iterable
{ {
yield 'with custom slug' => [ yield 'with custom slug' => [
new ShortUrl('', ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug'])), new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug'])),
'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.',
]; ];
yield 'already persisted' => [ yield 'already persisted' => [

View File

@@ -21,7 +21,7 @@ class ShortUrlMetaTest extends TestCase
public function exceptionIsThrownIfProvidedDataIsInvalid(array $data): void public function exceptionIsThrownIfProvidedDataIsInvalid(array $data): void
{ {
$this->expectException(ValidationException::class); $this->expectException(ValidationException::class);
ShortUrlMeta::createFromRawData($data); ShortUrlMeta::fromRawData($data);
} }
public function provideInvalidData(): iterable public function provideInvalidData(): iterable
@@ -49,7 +49,9 @@ class ShortUrlMetaTest extends TestCase
/** @test */ /** @test */
public function properlyCreatedInstanceReturnsValues(): void public function properlyCreatedInstanceReturnsValues(): void
{ {
$meta = ShortUrlMeta::createFromParams(Chronos::parse('2015-01-01')->toAtomString(), null, 'foobar'); $meta = ShortUrlMeta::fromRawData(
['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => 'foobar'],
);
$this->assertTrue($meta->hasValidSince()); $this->assertTrue($meta->hasValidSince());
$this->assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); $this->assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince());

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\ShortUrl;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
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\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver;
use function Functional\map;
use function range;
class ShortUrlResolverTest extends TestCase
{
private ShortUrlResolver $urlResolver;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->urlResolver = new ShortUrlResolver($this->em->reveal());
}
/** @test */
public function shortCodeIsProperlyParsed(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToShortUrl($shortCode);
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function exceptionIsThrownIfShortcodeIsNotFound(): void
{
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn(null);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$findOneByShortCode->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->shortCodeToShortUrl($shortCode);
}
/** @test */
public function shortCodeToEnabledShortUrlProperlyParsesShortCode(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode);
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideDisabledShortUrls
*/
public function shortCodeToEnabledShortUrlThrowsExceptionIfUrlIsNotEnabled(ShortUrl $shortUrl): void
{
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($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);
}
public function provideDisabledShortUrls(): iterable
{
$now = Chronos::now();
yield 'maxVisits reached' => [(function () {
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => 3]));
$shortUrl->setVisits(new ArrayCollection(map(
range(0, 4),
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
)));
return $shortUrl;
})()];
yield 'future validSince' => [new ShortUrl('', ShortUrlMeta::fromRawData([
'validSince' => $now->addMonth()->toAtomString(),
]))];
yield 'past validUntil' => [new ShortUrl('', ShortUrlMeta::fromRawData([
'validUntil' => $now->subMonth()->toAtomString(),
]))];
yield 'mixed' => [(function () use ($now) {
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData([
'maxVisits' => 3,
'validUntil' => $now->subMonth()->toAtomString(),
]));
$shortUrl->setVisits(new ArrayCollection(map(
range(0, 4),
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
)));
return $shortUrl;
})()];
}
}

View File

@@ -93,12 +93,11 @@ class ShortUrlServiceTest extends TestCase
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::createFromParams( $result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::fromRawData([
Chronos::parse('2017-01-01 00:00:00')->toAtomString(), 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
Chronos::parse('2017-01-05 00:00:00')->toAtomString(), 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
null, 'maxVisits' => 5,
5, ]));
));
$this->assertSame($shortUrl, $result); $this->assertSame($shortUrl, $result);
$this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince()); $this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince());

View File

@@ -19,7 +19,6 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
@@ -153,7 +152,7 @@ class UrlShortenerTest extends TestCase
$this->urlShortener->urlToShortCode( $this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'), new Uri('http://foobar.com/12345/hello?foo=bar'),
[], [],
ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug']), ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug']),
); );
} }
@@ -183,49 +182,49 @@ class UrlShortenerTest extends TestCase
{ {
$url = 'http://foo.com'; $url = 'http://foo.com';
yield [$url, [], ShortUrlMeta::createFromRawData(['findIfExists' => true]), new ShortUrl($url)]; yield [$url, [], ShortUrlMeta::fromRawData(['findIfExists' => true]), new ShortUrl($url)];
yield [$url, [], ShortUrlMeta::createFromRawData( yield [$url, [], ShortUrlMeta::fromRawData(
['findIfExists' => true, 'customSlug' => 'foo'], ['findIfExists' => true, 'customSlug' => 'foo'],
), new ShortUrl($url)]; ), new ShortUrl($url)];
yield [ yield [
$url, $url,
['foo', 'bar'], ['foo', 'bar'],
ShortUrlMeta::createFromRawData(['findIfExists' => true]), ShortUrlMeta::fromRawData(['findIfExists' => true]),
(new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])), (new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])),
]; ];
yield [ yield [
$url, $url,
[], [],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'maxVisits' => 3]), ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['maxVisits' => 3])), new ShortUrl($url, ShortUrlMeta::fromRawData(['maxVisits' => 3])),
]; ];
yield [ yield [
$url, $url,
[], [],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]), ShortUrlMeta::fromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validSince' => Chronos::parse('2017-01-01')])), new ShortUrl($url, ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2017-01-01')])),
]; ];
yield [ yield [
$url, $url,
[], [],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]), ShortUrlMeta::fromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validUntil' => Chronos::parse('2017-01-01')])), new ShortUrl($url, ShortUrlMeta::fromRawData(['validUntil' => Chronos::parse('2017-01-01')])),
]; ];
yield [ yield [
$url, $url,
[], [],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'domain' => 'example.com']), ShortUrlMeta::fromRawData(['findIfExists' => true, 'domain' => 'example.com']),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['domain' => 'example.com'])), new ShortUrl($url, ShortUrlMeta::fromRawData(['domain' => 'example.com'])),
]; ];
yield [ yield [
$url, $url,
['baz', 'foo', 'bar'], ['baz', 'foo', 'bar'],
ShortUrlMeta::createFromRawData([ ShortUrlMeta::fromRawData([
'findIfExists' => true, 'findIfExists' => true,
'validUntil' => Chronos::parse('2017-01-01'), 'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4, 'maxVisits' => 4,
]), ]),
(new ShortUrl($url, ShortUrlMeta::createFromRawData([ (new ShortUrl($url, ShortUrlMeta::fromRawData([
'validUntil' => Chronos::parse('2017-01-01'), 'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4, 'maxVisits' => 4,
])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])), ])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])),
@@ -237,7 +236,7 @@ class UrlShortenerTest extends TestCase
{ {
$url = 'http://foo.com'; $url = 'http://foo.com';
$tags = ['baz', 'foo', 'bar']; $tags = ['baz', 'foo', 'bar'];
$meta = ShortUrlMeta::createFromRawData([ $meta = ShortUrlMeta::fromRawData([
'findIfExists' => true, 'findIfExists' => true,
'validUntil' => Chronos::parse('2017-01-01'), 'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4, 'maxVisits' => 4,
@@ -260,18 +259,4 @@ class UrlShortenerTest extends TestCase
$findExisting->shouldHaveBeenCalledOnce(); $findExisting->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce();
} }
/** @test */
public function shortCodeIsProperlyParsed(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertSame($shortUrl, $url);
}
} }

View File

@@ -42,13 +42,13 @@ class ShortUrlDataTransformerTest extends TestCase
'validUntil' => null, 'validUntil' => null,
'maxVisits' => null, 'maxVisits' => null,
]]; ]];
yield 'max visits only' => [new ShortUrl('', ShortUrlMeta::createFromParams(null, null, null, $maxVisits)), [ yield 'max visits only' => [new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => $maxVisits])), [
'validSince' => null, 'validSince' => null,
'validUntil' => null, 'validUntil' => null,
'maxVisits' => $maxVisits, 'maxVisits' => $maxVisits,
]]; ]];
yield 'max visits and valid since' => [ yield 'max visits and valid since' => [
new ShortUrl('', ShortUrlMeta::createFromParams($now, null, null, $maxVisits)), new ShortUrl('', ShortUrlMeta::fromRawData(['validSince' => $now, 'maxVisits' => $maxVisits])),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
'validUntil' => null, 'validUntil' => null,
@@ -56,7 +56,9 @@ class ShortUrlDataTransformerTest extends TestCase
], ],
]; ];
yield 'both dates' => [ yield 'both dates' => [
new ShortUrl('', ShortUrlMeta::createFromParams($now, $now->subDays(10))), new ShortUrl('', ShortUrlMeta::fromRawData(
['validSince' => $now, 'validUntil' => $now->subDays(10)],
)),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
'validUntil' => $now->subDays(10)->toAtomString(), 'validUntil' => $now->subDays(10)->toAtomString(),
@@ -64,7 +66,9 @@ class ShortUrlDataTransformerTest extends TestCase
], ],
]; ];
yield 'everything' => [ yield 'everything' => [
new ShortUrl('', ShortUrlMeta::createFromParams($now, $now->subDays(5), null, $maxVisits)), new ShortUrl('', ShortUrlMeta::fromRawData(
['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits],
)),
[ [
'validSince' => $now->toAtomString(), 'validSince' => $now->toAtomString(),
'validUntil' => $now->subDays(5)->toAtomString(), 'validUntil' => $now->subDays(5)->toAtomString(),

View File

@@ -57,7 +57,10 @@ return [
], ],
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'], Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\ResolveShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'], Action\ShortUrl\ResolveShortUrlAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain',
],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'], Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
Action\ShortUrl\ListShortUrlsAction::class => [ Action\ShortUrl\ListShortUrlsAction::class => [
Service\ShortUrlService::class, Service\ShortUrlService::class,

View File

@@ -27,15 +27,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
]); ]);
} }
$meta = ShortUrlMeta::createFromParams( $meta = ShortUrlMeta::fromRawData($postData);
$postData['validSince'] ?? null,
$postData['validUntil'] ?? null,
$postData['customSlug'] ?? null,
$postData['maxVisits'] ?? null,
$postData['findIfExists'] ?? null,
$postData['domain'] ?? null,
);
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta); return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta);
} }
} }

View File

@@ -30,7 +30,7 @@ class EditShortUrlAction extends AbstractRestAction
$postData = (array) $request->getParsedBody(); $postData = (array) $request->getParsedBody();
$shortCode = $request->getAttribute('shortCode', ''); $shortCode = $request->getAttribute('shortCode', '');
$this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::createFromRawData($postData)); $this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::fromRawData($postData));
return new EmptyResponse(); return new EmptyResponse();
} }
} }

View File

@@ -4,12 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl; namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use InvalidArgumentException;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@@ -18,29 +17,26 @@ class ResolveShortUrlAction extends AbstractRestAction
protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_PATH = '/short-urls/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private UrlShortenerInterface $urlShortener; private ShortUrlResolverInterface $urlResolver;
private array $domainConfig; private array $domainConfig;
public function __construct( public function __construct(
UrlShortenerInterface $urlShortener, ShortUrlResolverInterface $urlResolver,
array $domainConfig, array $domainConfig,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
) { ) {
parent::__construct($logger); parent::__construct($logger);
$this->urlShortener = $urlShortener; $this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
} }
/**
* @throws InvalidArgumentException
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$shortCode = $request->getAttribute('shortCode'); $shortCode = $request->getAttribute('shortCode');
$domain = $request->getQueryParams()['domain'] ?? null; $domain = $request->getQueryParams()['domain'] ?? null;
$transformer = new ShortUrlDataTransformer($this->domainConfig); $transformer = new ShortUrlDataTransformer($this->domainConfig);
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); $url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url)); return new JsonResponse($transformer->transform($url));
} }
} }

View File

@@ -4,11 +4,71 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action; namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use GuzzleHttp\RequestOptions; use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function Functional\first;
use function sprintf;
class EditShortUrlActionTest extends ApiTestCase class EditShortUrlActionTest extends ApiTestCase
{ {
use ArraySubsetAsserts;
/**
* @test
* @dataProvider provideMeta
*/
public function metadataCanBeReset(array $meta): void
{
$shortCode = 'abc123';
$url = sprintf('/short-urls/%s', $shortCode);
$resetMeta = [
'validSince' => null,
'validUntil' => null,
'maxVisits' => null,
];
$editWithProvidedMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $meta]);
$metaAfterEditing = $this->findShortUrlMetaByShortCode($shortCode);
$editWithResetMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [
RequestOptions::JSON => $resetMeta,
]);
$metaAfterResetting = $this->findShortUrlMetaByShortCode($shortCode);
$this->assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode());
$this->assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode());
$this->assertEquals($resetMeta, $metaAfterResetting);
self::assertArraySubset($meta, $metaAfterEditing);
}
public function provideMeta(): iterable
{
$now = Chronos::now();
yield [['validSince' => $now->addMonth()->toAtomString()]];
yield [['validUntil' => $now->subMonth()->toAtomString()]];
yield [['maxVisits' => 20]];
yield [['validUntil' => $now->addYear()->toAtomString(), 'maxVisits' => 100]];
yield [[
'validSince' => $now->subYear()->toAtomString(),
'validUntil' => $now->addYear()->toAtomString(),
'maxVisits' => 100,
]];
}
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);
return $matchingShortUrl['meta'] ?? null;
}
/** @test */ /** @test */
public function tryingToEditInvalidUrlReturnsNotFoundError(): void public function tryingToEditInvalidUrlReturnsNotFoundError(): void
{ {

View File

@@ -4,10 +4,42 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action; namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
class ResolveShortUrlActionTest extends ApiTestCase class ResolveShortUrlActionTest extends ApiTestCase
{ {
/**
* @test
* @dataProvider provideDisabledMeta
*/
public function shortUrlIsProperlyResolvedEvenWhenNotEnabled(array $disabledMeta): void
{
$shortCode = 'abc123';
$url = sprintf('/short-urls/%s', $shortCode);
$this->callShortUrl($shortCode);
$editResp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $disabledMeta]);
$visitResp = $this->callShortUrl($shortCode);
$fetchResp = $this->callApiWithKey(self::METHOD_GET, $url);
$this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
$this->assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode());
$this->assertEquals(self::STATUS_OK, $fetchResp->getStatusCode());
}
public function provideDisabledMeta(): iterable
{
$now = Chronos::now();
yield 'future validSince' => [['validSince' => $now->addMonth()->toAtomString()]];
yield 'past validUntil' => [['validUntil' => $now->subMonth()->toAtomString()]];
yield 'maxVisits reached' => [['maxVisits' => 1]];
}
/** @test */ /** @test */
public function tryingToResolveInvalidUrlReturnsNotFoundError(): void public function tryingToResolveInvalidUrlReturnsNotFoundError(): void
{ {

View File

@@ -20,32 +20,32 @@ class ShortUrlsFixture extends AbstractFixture
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$abcShortUrl = $this->setShortUrlDate( $abcShortUrl = $this->setShortUrlDate(
new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123'])), new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData(['customSlug' => 'abc123'])),
'2018-05-01', '2018-05-01',
); );
$manager->persist($abcShortUrl); $manager->persist($abcShortUrl);
$defShortUrl = $this->setShortUrlDate(new ShortUrl( $defShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
ShortUrlMeta::createFromParams(Chronos::parse('2020-05-01'), null, 'def456'), ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456']),
), '2019-01-01 00:00:10'); ), '2019-01-01 00:00:10');
$manager->persist($defShortUrl); $manager->persist($defShortUrl);
$customShortUrl = $this->setShortUrlDate(new ShortUrl( $customShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://shlink.io', 'https://shlink.io',
ShortUrlMeta::createFromParams(null, null, 'custom', 2), ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2]),
), '2019-01-01 00:00:20'); ), '2019-01-01 00:00:20');
$manager->persist($customShortUrl); $manager->persist($customShortUrl);
$withDomainShortUrl = $this->setShortUrlDate(new ShortUrl( $withDomainShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
ShortUrlMeta::createFromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']), ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']),
), '2019-01-01 00:00:30'); ), '2019-01-01 00:00:30');
$manager->persist($withDomainShortUrl); $manager->persist($withDomainShortUrl);
$withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl( $withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://google.com', 'https://google.com',
ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']), ShortUrlMeta::fromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']),
), '2018-10-20'); ), '2018-10-20');
$manager->persist($withDomainAndSlugShortUrl); $manager->persist($withDomainAndSlugShortUrl);

View File

@@ -76,7 +76,7 @@ class CreateShortUrlActionTest extends TestCase
]; ];
yield [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()]; yield [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()];
yield [$fullMeta, ShortUrlMeta::createFromRawData($fullMeta)]; yield [$fullMeta, ShortUrlMeta::fromRawData($fullMeta)];
} }
/** /**

View File

@@ -8,7 +8,7 @@ use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction;
use function strpos; use function strpos;
@@ -16,19 +16,19 @@ use function strpos;
class ResolveShortUrlActionTest extends TestCase class ResolveShortUrlActionTest extends TestCase
{ {
private ResolveShortUrlAction $action; private ResolveShortUrlAction $action;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlResolver;
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->action = new ResolveShortUrlAction($this->urlShortener->reveal(), []); $this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), []);
} }
/** @test */ /** @test */
public function correctShortCodeReturnsSuccess(): void public function correctShortCodeReturnsSuccess(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn( $this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn(
new ShortUrl('http://domain.com/foo/bar'), new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce(); )->shouldBeCalledOnce();