Merge pull request #343 from acelaya/feature/allow-check-duplicates

Feature/allow check duplicates
This commit is contained in:
Alejandro Celaya 2019-02-03 12:22:22 +01:00 committed by GitHub
commit 79c132219b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 616 additions and 274 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ composer.phar
vendor/
.env
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.mmdb
docs/swagger-ui*
docker-compose.override.yml

View File

@ -29,6 +29,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/).
* [#279](https://github.com/shlinkio/shlink/issues/279) Added new `findIfExists` flag to the `[POST /short-url]` REST endpoint and the `short-urls:generate` CLI command. It can be used to return existing short URLs when found, instead of creating new ones.
Thanks to this flag you won't need to remember if you created a short URL for a long one. It will just create it if needed or return the existing one if possible.
The behavior might be a little bit counterintuitive when combined with other params. This is how the endpoint behaves when providing this new flag:
* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.
* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.
* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.
* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service.
#### Changed

View File

@ -1,12 +1,23 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Psr\Log;
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
'initializers' => [
function (ContainerInterface $container, $instance) {
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
},
],
],
];

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Cocur\Slugify\Slugify;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'slugify_options' => [
'lowercase' => false,
],
'dependencies' => [
'factories' => [
Slugify::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
Slugify::class => ['config.slugify_options'],
],
];

View File

@ -17,10 +17,10 @@ if (! file_exists('.env')) {
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(TestHelper::class);
$config = $container->get('config');
$testHelper->createTestDb();
$em = $container->get(EntityManager::class);
$testHelper->seedFixtures($em, $config['data_fixtures'] ?? []);
$testHelper->createTestDb($config['entity_manager']['connection']['path']);
ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client'));
ApiTest\ApiTestCase::setSeedFixturesCallback(function () use ($testHelper, $em, $config) {
$testHelper->seedFixtures($em, $config['data_fixtures'] ?? []);
});

View File

@ -14,6 +14,7 @@ if (! file_exists('.env')) {
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$config = $container->get('config');
$container->get(TestHelper::class)->createTestDb();
$container->get(TestHelper::class)->createTestDb($config['entity_manager']['connection']['path']);
DbTest\DatabaseTestCase::setEntityManager($container->get('em'));

View File

@ -6,9 +6,12 @@ namespace ShlinkioTest\Shlink;
use GuzzleHttp\Client;
use Zend\ConfigAggregator\ConfigAggregator;
use Zend\ServiceManager\Factory\InvokableFactory;
use function realpath;
use function sprintf;
use function sys_get_temp_dir;
$swooleTestingHost = '127.0.0.1';
$swooleTestingPort = 9999;
return [
'debug' => true,
@ -23,8 +26,8 @@ return [
'zend-expressive-swoole' => [
'swoole-http-server' => [
'port' => 9999,
'host' => '127.0.0.1',
'host' => $swooleTestingHost,
'port' => $swooleTestingPort,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
@ -33,18 +36,22 @@ return [
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort),
'http_errors' => false,
]),
],
'factories' => [
Common\TestHelper::class => InvokableFactory::class,
'shlink_test_api_client' => function () {
return new Client(['base_uri' => 'http://localhost:9999/']);
},
],
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_sqlite',
'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
// 'path' => __DIR__ . '/../../data/shlink-tests.db',
],
],

View File

@ -151,7 +151,7 @@
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"security": [
{
"ApiKey": []
@ -197,6 +197,10 @@
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
}
}
}

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -22,13 +22,13 @@ class GenerateCharsetCommand extends Command
->setDescription(sprintf(
'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable',
UrlShortener::DEFAULT_CHARS
UrlShortenerOptions::DEFAULT_CHARS
));
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
$charSet = str_shuffle(UrlShortenerOptions::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet));
}
}

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
use Symfony\Component\Console\Command\Command;
@ -15,8 +16,10 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Diactoros\Uri;
use function array_merge;
use function explode;
use function array_map;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
use function sprintf;
class GenerateShortUrlCommand extends Command
@ -76,6 +79,12 @@ class GenerateShortUrlCommand extends Command
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.'
)
->addOption(
'findIfExists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.'
);
}
@ -102,13 +111,8 @@ class GenerateShortUrlCommand extends Command
return;
}
$tags = $input->getOption('tags');
$processedTags = [];
foreach ($tags as $key => $tag) {
$explodedTags = explode(',', $tag);
$processedTags = array_merge($processedTags, $explodedTags);
}
$tags = $processedTags;
$explodeWithComma = curry('explode')(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
@ -116,10 +120,13 @@ class GenerateShortUrlCommand extends Command
$shortCode = $this->urlShortener->urlToShortCode(
new Uri($longUrl),
$tags,
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null
ShortUrlMeta::createFromParams(
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null,
$input->getOption('findIfExists')
)
)->getShortCode();
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);

View File

@ -90,7 +90,7 @@ class GenerateShortUrlCommandTest extends TestCase
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar'],
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
]);
$output = $this->commandTester->getDisplay();

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Validation;
use Zend\Filter;
use Zend\InputFilter\Input;
use Zend\Validator;
trait InputFactoryTrait
{
private function createInput($name, $required = true): Input
{
$input = new Input($name);
$input->setRequired($required)
->getFilterChain()->attach(new Filter\StripTags())
->attach(new Filter\StringTrim());
return $input;
}
private function createBooleanInput(string $name, bool $required = true): Input
{
$input = $this->createInput($name, $required);
$input->getFilterChain()->attach(new Filter\Boolean());
$input->getValidatorChain()->attach(new Validator\NotEmpty(['type' => [
Validator\NotEmpty::OBJECT,
Validator\NotEmpty::SPACE,
Validator\NotEmpty::NULL,
Validator\NotEmpty::EMPTY_ARRAY,
Validator\NotEmpty::STRING,
]]));
return $input;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Validation;
use Cocur\Slugify;
use Zend\Filter\Exception;
use Zend\Filter\FilterInterface;
class SluggerFilter implements FilterInterface
{
/** @var Slugify\SlugifyInterface */
private $slugger;
public function __construct(?Slugify\SlugifyInterface $slugger = null)
{
$this->slugger = $slugger ?: new Slugify\Slugify(['lowercase' => false]);
}
/**
* Returns the result of filtering $value
*
* @param mixed $value
* @throws Exception\RuntimeException If filtering $value is impossible
* @return mixed
*/
public function filter($value)
{
return ! empty($value) ? $this->slugger->slugify($value) : null;
}
}

View File

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Common\ApiTest;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Rest\Authentication\Plugin\ApiKeyHeaderPlugin;
@ -14,32 +15,40 @@ use function sprintf;
abstract class ApiTestCase extends TestCase implements StatusCodeInterface, RequestMethodInterface
{
private const PATH_PREFX = '/rest/v1';
private const REST_PATH_PREFIX = '/rest/v1';
/** @var ClientInterface */
private static $client;
/** @var callable */
private static $seedFixtures;
public static function setApiClient(ClientInterface $client): void
{
self::$client = $client;
}
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
protected function callApi(string $method, string $uri, array $options = []): ResponseInterface
public static function setSeedFixturesCallback(callable $seedFixtures): void
{
return self::$client->request($method, sprintf('%s%s', self::PATH_PREFX, $uri), $options);
self::$seedFixtures = $seedFixtures;
}
public function setUp(): void
{
if (self::$seedFixtures) {
(self::$seedFixtures)();
}
}
protected function callApi(string $method, string $uri, array $options = []): ResponseInterface
{
return self::$client->request($method, sprintf('%s%s', self::REST_PATH_PREFIX, $uri), $options);
}
/**
* @throws \GuzzleHttp\Exception\GuzzleException
*/
protected function callApiWithKey(string $method, string $uri, array $options = []): ResponseInterface
{
$headers = $options['headers'] ?? [];
$headers = $options[RequestOptions::HEADERS] ?? [];
$headers[ApiKeyHeaderPlugin::HEADER_NAME] = 'valid_api_key';
$options['headers'] = $headers;
$options[RequestOptions::HEADERS] = $headers;
return $this->callApi($method, $uri, $options);
}
@ -48,4 +57,11 @@ abstract class ApiTestCase extends TestCase implements StatusCodeInterface, Requ
{
return json_decode((string) $resp->getBody());
}
protected function callShortUrl(string $shortCode): ResponseInterface
{
return self::$client->request(self::METHOD_GET, sprintf('/%s', $shortCode), [
RequestOptions::ALLOW_REDIRECTS => false,
]);
}
}

View File

@ -5,18 +5,16 @@ namespace ShlinkioTest\Shlink\Common;
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
use Doctrine\Common\DataFixtures\Loader;
use Doctrine\Common\DataFixtures\Purger\ORMPurger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Process\Process;
use function file_exists;
use function realpath;
use function sys_get_temp_dir;
use function unlink;
class TestHelper
{
public function createTestDb(): void
public function createTestDb(string $shlinkDbPath): void
{
$shlinkDbPath = realpath(sys_get_temp_dir()) . '/shlink-tests.db';
if (file_exists($shlinkDbPath)) {
unlink($shlinkDbPath);
}
@ -38,7 +36,7 @@ class TestHelper
$loader->loadFromDirectory($path);
}
$executor = new ORMExecutor($em);
$executor->execute($loader->getFixtures(), true);
$executor = new ORMExecutor($em, new ORMPurger());
$executor->execute($loader->getFixtures());
}
}

View File

@ -3,7 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Cocur\Slugify\Slugify;
use Doctrine\Common\Cache\Cache;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
@ -46,7 +45,7 @@ return [
Options\NotFoundShortUrlOptions::class => ['config.url_shortener.not_found_short_url'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => ['httpClient', 'em', Options\UrlShortenerOptions::class, Slugify::class],
Service\UrlShortener::class => ['httpClient', 'em', Options\UrlShortenerOptions::class],
Service\VisitsTracker::class => ['em'],
Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'],

View File

@ -8,7 +8,7 @@ use function sprintf;
class InvalidUrlException extends RuntimeException
{
public static function fromUrl($url, Throwable $previous = null)
public static function fromUrl(string $url, Throwable $previous = null)
{
$code = $previous !== null ? $previous->getCode() : -1;
return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous);

View File

@ -17,11 +17,11 @@ final class CreateShortUrlData
public function __construct(
UriInterface $longUrl,
array $tags = [],
ShortUrlMeta $meta = null
?ShortUrlMeta $meta = null
) {
$this->longUrl = $longUrl;
$this->tags = $tags;
$this->meta = $meta ?? ShortUrlMeta::createFromParams();
$this->meta = $meta ?? ShortUrlMeta::createEmpty();
}
/**

View File

@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function is_string;
final class ShortUrlMeta
{
@ -18,6 +17,8 @@ final class ShortUrlMeta
private $customSlug;
/** @var int|null */
private $maxVisits;
/** @var bool|null */
private $findIfExists;
// Force named constructors
private function __construct()
@ -45,21 +46,25 @@ final class ShortUrlMeta
* @param string|Chronos|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @param bool|null $findIfExists
* @throws ValidationException
*/
public static function createFromParams(
$validSince = null,
$validUntil = null,
$customSlug = null,
$maxVisits = null
$maxVisits = null,
$findIfExists = null
): self {
// We do not type hint the arguments because that will be done by the validation process
// 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,
]);
return $instance;
}
@ -80,11 +85,11 @@ final class ShortUrlMeta
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
}
/**
* @param string|Chronos|null $date
* @return Chronos|null
*/
private function parseDateField($date): ?Chronos
{
@ -92,11 +97,7 @@ final class ShortUrlMeta
return $date;
}
if (is_string($date)) {
return Chronos::parse($date);
}
return null;
return Chronos::parse($date);
}
public function getValidSince(): ?Chronos
@ -138,4 +139,9 @@ final class ShortUrlMeta
{
return $this->maxVisits !== null;
}
public function findIfExists(): bool
{
return (bool) $this->findIfExists;
}
}

View File

@ -3,11 +3,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Cake\Chronos\Chronos;
use Cocur\Slugify\SlugifyInterface;
use Doctrine\ORM\EntityManagerInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
@ -20,8 +20,12 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Throwable;
use function array_reduce;
use function count;
use function floor;
use function fmod;
use function Functional\contains;
use function Functional\invoke;
use function preg_match;
use function strlen;
@ -29,67 +33,60 @@ class UrlShortener implements UrlShortenerInterface
{
use TagManagerTrait;
/** @deprecated */
public const DEFAULT_CHARS = UrlShortenerOptions::DEFAULT_CHARS;
private const ID_INCREMENT = 200000;
/** @var ClientInterface */
private $httpClient;
/** @var EntityManagerInterface */
private $em;
/** @var SlugifyInterface */
private $slugger;
/** @var UrlShortenerOptions */
private $options;
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
UrlShortenerOptions $options,
SlugifyInterface $slugger
) {
public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options)
{
$this->httpClient = $httpClient;
$this->em = $em;
$this->options = $options;
$this->slugger = $slugger;
}
/**
* @param string[] $tags
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
*/
public function urlToShortCode(
UriInterface $url,
array $tags = [],
?Chronos $validSince = null,
?Chronos $validUntil = null,
?string $customSlug = null,
?int $maxVisits = null
): ShortUrl {
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
{
$url = (string) $url;
// First, check if a short URL exists for all provided params
$existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
if ($existingShortUrl !== null) {
return $existingShortUrl;
}
// If the URL validation is enabled, check that the URL actually exists
if ($this->options->isUrlValidationEnabled()) {
$this->checkUrlExists($url);
}
$customSlug = $this->processCustomSlug($customSlug);
$this->verifyCustomSlug($meta);
// Transactionally insert the short url, then generate the short code and finally update the short code
try {
$this->em->beginTransaction();
// First, create the short URL with an empty short code
$shortUrl = new ShortUrl(
(string) $url,
ShortUrlMeta::createFromParams($validSince, $validUntil, null, $maxVisits)
);
$shortUrl = new ShortUrl($url, $meta);
$this->em->persist($shortUrl);
$this->em->flush();
// Generate the short code and persist it
// TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
$shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
$shortUrl->setShortCode($shortCode)
->setTags($this->tagNamesToEntities($this->em, $tags));
// Generate the short code and persist it if no custom slug was provided
if (! $meta->hasCustomSlug()) {
// TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
$shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
$shortUrl->setShortCode($shortCode);
}
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
$this->em->commit();
@ -104,17 +101,71 @@ class UrlShortener implements UrlShortenerInterface
}
}
private function checkUrlExists(UriInterface $url): void
private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
{
if (! $meta->findIfExists()) {
return null;
}
$criteria = ['longUrl' => $url];
if ($meta->hasCustomSlug()) {
$criteria['shortCode'] = $meta->getCustomSlug();
}
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy($criteria);
if ($shortUrl === null) {
return null;
}
if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $shortUrl->getMaxVisits()) {
return null;
}
if ($meta->hasValidSince() && ! $meta->getValidSince()->eq($shortUrl->getValidSince())) {
return null;
}
if ($meta->hasValidUntil() && ! $meta->getValidUntil()->eq($shortUrl->getValidUntil())) {
return null;
}
$shortUrlTags = invoke($shortUrl->getTags(), '__toString');
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
$tags,
function (bool $hasAllTags, string $tag) use ($shortUrlTags) {
return $hasAllTags && contains($shortUrlTags, $tag);
},
true
);
return $hasAllTags ? $shortUrl : null;
}
private function checkUrlExists(string $url): void
{
try {
$this->httpClient->request('GET', $url, ['allow_redirects' => [
'max' => 15,
]]);
$this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
RequestOptions::ALLOW_REDIRECTS => ['max' => 15],
]);
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
}
}
private function verifyCustomSlug(ShortUrlMeta $meta): void
{
if (! $meta->hasCustomSlug()) {
return;
}
$customSlug = $meta->getCustomSlug();
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$shortUrlsCount = $repo->count(['shortCode' => $customSlug]);
if ($shortUrlsCount > 0) {
throw NonUniqueSlugException::fromSlug($customSlug);
}
}
private function convertAutoincrementIdToShortCode(float $id): string
{
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
@ -132,25 +183,7 @@ class UrlShortener implements UrlShortenerInterface
return $chars[(int) $id] . $code;
}
private function processCustomSlug(?string $customSlug): ?string
{
if ($customSlug === null) {
return null;
}
// If a custom slug was provided, make sure it's unique
$customSlug = $this->slugger->slugify($customSlug);
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
if ($shortUrl !== null) {
throw NonUniqueSlugException::fromSlug($customSlug);
}
return $customSlug;
}
/**
* Tries to find the mapped URL for provided short code. Returns null if not found
*
* @throws InvalidShortCodeException
* @throws EntityDoesNotExistException
*/

View File

@ -3,7 +3,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Cake\Chronos\Chronos;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
@ -11,26 +10,19 @@ use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
interface UrlShortenerInterface
{
/**
* @param string[] $tags
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
*/
public function urlToShortCode(
UriInterface $url,
array $tags = [],
?Chronos $validSince = null,
?Chronos $validUntil = null,
?string $customSlug = null,
?int $maxVisits = null
): ShortUrl;
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
/**
* Tries to find the mapped URL for provided short code. Returns null if not found
*
* @throws InvalidShortCodeException
* @throws EntityDoesNotExistException
*/

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Zend\Filter\StringTrim;
use Zend\Filter\StripTags;
use Zend\InputFilter\Input;
trait InputFactoryTrait
{
private function createInput($name, $required = true): Input
{
$input = new Input($name);
$input->setRequired($required)
->getFilterChain()->attach(new StripTags())
->attach(new StringTrim());
return $input;
}
}

View File

@ -4,19 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use DateTime;
use Zend\I18n\Validator\IsInt;
use Shlinkio\Shlink\Common\Validation;
use Zend\InputFilter\InputFilter;
use Zend\Validator\Date;
use Zend\Validator\GreaterThan;
use Zend\Validator;
class ShortUrlMetaInputFilter extends InputFilter
{
use InputFactoryTrait;
use Validation\InputFactoryTrait;
public const VALID_SINCE = 'validSince';
public const VALID_UNTIL = 'validUntil';
public const CUSTOM_SLUG = 'customSlug';
public const MAX_VISITS = 'maxVisits';
public const FIND_IF_EXISTS = 'findIfExists';
public function __construct(?array $data = null)
{
@ -29,18 +29,22 @@ class ShortUrlMetaInputFilter extends InputFilter
private function initialize(): void
{
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Date(['format' => DateTime::ATOM]));
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
$this->add($validSince);
$validUntil = $this->createInput(self::VALID_UNTIL, false);
$validUntil->getValidatorChain()->attach(new Date(['format' => DateTime::ATOM]));
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
$this->add($validUntil);
$this->add($this->createInput(self::CUSTOM_SLUG, false));
$customSlug = $this->createInput(self::CUSTOM_SLUG, false);
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter());
$this->add($customSlug);
$maxVisits = $this->createInput(self::MAX_VISITS, false);
$maxVisits->getValidatorChain()->attach(new IsInt())
->attach(new GreaterThan(['min' => 1, 'inclusive' => true]));
$maxVisits->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
$this->add($maxVisits);
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
}
}

View File

@ -3,8 +3,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service;
use Cocur\Slugify\SlugifyInterface;
use Doctrine\Common\Persistence\ObjectRepository;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
@ -16,8 +16,11 @@ use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Zend\Diactoros\Uri;
@ -30,10 +33,8 @@ class UrlShortenerTest extends TestCase
private $em;
/** @var ObjectProphecy */
private $httpClient;
/** @var ObjectProphecy */
private $slugger;
public function setUp()
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
@ -49,32 +50,33 @@ class UrlShortenerTest extends TestCase
$shortUrl = $arguments[0];
$shortUrl->setId('10');
});
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn(null);
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->count(Argument::any())->willReturn(0);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->slugger = $this->prophesize(SlugifyInterface::class);
$this->setUrlShortener(false);
}
public function setUrlShortener(bool $urlValidationEnabled)
public function setUrlShortener(bool $urlValidationEnabled): void
{
$this->urlShortener = new UrlShortener(
$this->httpClient->reveal(),
$this->em->reveal(),
new UrlShortenerOptions(['validate_url' => $urlValidationEnabled]),
$this->slugger->reveal()
new UrlShortenerOptions(['validate_url' => $urlValidationEnabled])
);
}
/**
* @test
*/
public function urlIsProperlyShortened()
public function urlIsProperlyShortened(): void
{
// 10 -> 12C1c
$shortUrl = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$shortUrl = $this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
ShortUrlMeta::createEmpty()
);
$this->assertEquals('12C1c', $shortUrl->getShortCode());
}
@ -82,7 +84,7 @@ class UrlShortenerTest extends TestCase
* @test
* @expectedException \Shlinkio\Shlink\Core\Exception\RuntimeException
*/
public function exceptionIsThrownWhenOrmThrowsException()
public function exceptionIsThrownWhenOrmThrowsException(): void
{
$conn = $this->prophesize(Connection::class);
$conn->isTransactionActive()->willReturn(true);
@ -91,75 +93,127 @@ class UrlShortenerTest extends TestCase
$this->em->close()->shouldBeCalledOnce();
$this->em->flush()->willThrow(new ORMException());
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
ShortUrlMeta::createEmpty()
);
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Core\Exception\InvalidUrlException
*/
public function exceptionIsThrownWhenUrlDoesNotExist()
public function exceptionIsThrownWhenUrlDoesNotExist(): void
{
$this->setUrlShortener(true);
$this->httpClient->request(Argument::cetera())->willThrow(
new ClientException('', $this->prophesize(Request::class)->reveal())
);
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
}
/**
* @test
*/
public function whenCustomSlugIsProvidedItIsUsed()
{
/** @var MethodProphecy $slugify */
$slugify = $this->slugger->slugify('custom-slug')->willReturnArgument();
$this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
null,
null,
'custom-slug'
ShortUrlMeta::createEmpty()
);
$slugify->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function exceptionIsThrownWhenNonUniqueSlugIsProvided()
public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
{
/** @var MethodProphecy $slugify */
$slugify = $this->slugger->slugify('custom-slug')->willReturnArgument();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
/** @var MethodProphecy $findBySlug */
$findBySlug = $repo->findOneBy(['shortCode' => 'custom-slug'])->willReturn(new ShortUrl(''));
$repo = $this->prophesize(ShortUrlRepository::class);
$countBySlug = $repo->count(['shortCode' => 'custom-slug'])->willReturn(1);
$repo->findOneBy(Argument::cetera())->willReturn(null);
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$slugify->shouldBeCalledOnce();
$findBySlug->shouldBeCalledOnce();
$countBySlug->shouldBeCalledOnce();
$getRepo->shouldBeCalled();
$this->expectException(NonUniqueSlugException::class);
$this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
null,
null,
'custom-slug'
ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug'])
);
}
/**
* @test
* @dataProvider provideExsitingShortUrls
*/
public function shortCodeIsProperlyParsed()
public function existingShortUrlIsReturnedWhenRequested(
string $url,
array $tags,
ShortUrlMeta $meta,
?ShortUrl $expected
): void {
$repo = $this->prophesize(ShortUrlRepository::class);
$findExisting = $repo->findOneBy(Argument::any())->willReturn($expected);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlShortener->urlToShortCode(new Uri($url), $tags, $meta);
$this->assertSame($expected, $result);
$findExisting->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
public function provideExsitingShortUrls(): array
{
$url = 'http://foo.com';
return [
[$url, [], ShortUrlMeta::createFromRawData(['findIfExists' => true]), new ShortUrl($url)],
[$url, [], ShortUrlMeta::createFromRawData(
['findIfExists' => true, 'customSlug' => 'foo']
), new ShortUrl($url)],
[
$url,
['foo', 'bar'],
ShortUrlMeta::createFromRawData(['findIfExists' => true]),
(new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])),
],
[
$url,
[],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'maxVisits' => 3]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['maxVisits' => 3])),
],
[
$url,
[],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validSince' => Chronos::parse('2017-01-01')])),
],
[
$url,
[],
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]),
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validUntil' => Chronos::parse('2017-01-01')])),
],
[
$url,
['baz', 'foo', 'bar'],
ShortUrlMeta::createFromRawData([
'findIfExists' => true,
'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4,
]),
(new ShortUrl($url, ShortUrlMeta::createFromRawData([
'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4,
])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])),
],
];
}
/**
* @test
*/
public function shortCodeIsProperlyParsed(): void
{
// 12C1c -> 10
$shortCode = '12C1c';
@ -178,7 +232,7 @@ class UrlShortenerTest extends TestCase
* @test
* @expectedException \Shlinkio\Shlink\Core\Exception\InvalidShortCodeException
*/
public function invalidCharSetThrowsException()
public function invalidCharSetThrowsException(): void
{
$this->urlShortener->shortCodeToUrl('&/(');
}

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
@ -66,7 +66,7 @@ class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
case self::CHARS:
return $io->ask(
'Character set for generated short codes (leave empty to autogenerate one)'
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS);
) ?: str_shuffle(UrlShortenerOptions::DEFAULT_CHARS);
case self::VALIDATE_URL:
return $io->confirm('Do you want to validate long urls by 200 HTTP status code on response');
case self::ENABLE_NOT_FOUND_REDIRECTION:

View File

@ -38,15 +38,11 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
*/
public function handle(Request $request): Response
{
try {
$shortUrlData = $this->buildShortUrlData($request);
$shortUrlMeta = $shortUrlData->getMeta();
$longUrl = $shortUrlData->getLongUrl();
$customSlug = $shortUrlMeta->getCustomSlug();
} catch (InvalidArgumentException $e) {
$this->logger->warning('Provided data is invalid. {e}', ['e' => $e]);
return new JsonResponse([
@ -55,15 +51,11 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
], self::STATUS_BAD_REQUEST);
}
$longUrl = $shortUrlData->getLongUrl();
$shortUrlMeta = $shortUrlData->getMeta();
try {
$shortUrl = $this->urlShortener->urlToShortCode(
$longUrl,
$shortUrlData->getTags(),
$shortUrlMeta->getValidSince(),
$shortUrlMeta->getValidUntil(),
$customSlug,
$shortUrlMeta->getMaxVisits()
);
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $shortUrlData->getTags(), $shortUrlMeta);
$transformer = new ShortUrlDataTransformer($this->domainConfig);
return new JsonResponse($transformer->transform($shortUrl));
@ -74,6 +66,7 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
'message' => sprintf('Provided URL %s is invalid. Try with a different one.', $longUrl),
], self::STATUS_BAD_REQUEST);
} catch (NonUniqueSlugException $e) {
$customSlug = $shortUrlMeta->getCustomSlug();
$this->logger->warning('Provided non-unique slug. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),

View File

@ -35,7 +35,8 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$postData['customSlug'] ?? null,
isset($postData['maxVisits']) ? (int) $postData['maxVisits'] : null
$postData['maxVisits'] ?? null,
$postData['findIfExists'] ?? null
)
);
}

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions;
use ShlinkioTest\Shlink\Common\ApiTest\ApiTestCase;
class CreateShortUrlActionTest extends ApiTestCase
{
/**
* @test
*/
public function createsNewShortUrlWhenOnlyLongUrlIsProvided(): void
{
$expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags'];
[$statusCode, $payload] = $this->createShortUrl();
$this->assertEquals(self::STATUS_OK, $statusCode);
foreach ($expectedKeys as $key) {
$this->assertArrayHasKey($key, $payload);
}
}
/**
* @test
*/
public function createsNewShortUrlWithCustomSlug(): void
{
[$statusCode, $payload] = $this->createShortUrl(['customSlug' => 'my cool slug']);
$this->assertEquals(self::STATUS_OK, $statusCode);
$this->assertEquals('my-cool-slug', $payload['shortCode']);
}
/**
* @test
*/
public function createsNewShortUrlWithTags(): void
{
[$statusCode, $payload] = $this->createShortUrl(['tags' => ['foo', 'bar', 'baz']]);
$this->assertEquals(self::STATUS_OK, $statusCode);
$this->assertEquals(['foo', 'bar', 'baz'], $payload['tags']);
}
/**
* @test
* @dataProvider provideMaxVisits
*/
public function createsNewShortUrlWithVisitsLimit(int $maxVisits): void
{
[$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl(['maxVisits' => $maxVisits]);
$this->assertEquals(self::STATUS_OK, $statusCode);
// Last request to the short URL will return a 404, and the rest, a 302
for ($i = 0; $i < $maxVisits; $i++) {
$this->assertEquals(self::STATUS_FOUND, $this->callShortUrl($shortCode)->getStatusCode());
}
$lastResp = $this->callShortUrl($shortCode);
$this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
}
public function provideMaxVisits(): array
{
return [
[1],
[5],
[3],
];
}
/**
* @test
*/
public function createsShortUrlWithValidSince(): void
{
[$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
'validSince' => Chronos::now()->addDay()->toAtomString(),
]);
$this->assertEquals(self::STATUS_OK, $statusCode);
// Request to the short URL will return a 404 since ist' not valid yet
$lastResp = $this->callShortUrl($shortCode);
$this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
}
/**
* @test
*/
public function createsShortUrlWithValidUntil(): void
{
[$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
'validUntil' => Chronos::now()->subDay()->toAtomString(),
]);
$this->assertEquals(self::STATUS_OK, $statusCode);
// Request to the short URL will return a 404 since it's no longer valid
$lastResp = $this->callShortUrl($shortCode);
$this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
}
/**
* @test
* @dataProvider provideMatchingBodies
*/
public function returnsAnExistingShortUrlWhenRequested(array $body): void
{
[$firstStatusCode, ['shortCode' => $firstShortCode]] = $this->createShortUrl($body);
$body['findIfExists'] = true;
[$secondStatusCode, ['shortCode' => $secondShortCode]] = $this->createShortUrl($body);
$this->assertEquals(self::STATUS_OK, $firstStatusCode);
$this->assertEquals(self::STATUS_OK, $secondStatusCode);
$this->assertEquals($firstShortCode, $secondShortCode);
}
public function provideMatchingBodies(): array
{
$longUrl = 'https://www.alejandrocelaya.com';
return [
'only long URL' => [['longUrl' => $longUrl]],
'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]],
'long URL custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']],
'several params' => [[
'longUrl' => $longUrl,
'tags' => ['boo', 'far'],
'validSince' => Chronos::now()->toAtomString(),
'maxVisits' => 7,
]],
];
}
/**
* @test
*/
public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(): void
{
$longUrl = 'https://www.alejandrocelaya.com';
[$firstStatusCode] = $this->createShortUrl(['longUrl' => $longUrl]);
[$secondStatusCode] = $this->createShortUrl([
'longUrl' => $longUrl,
'customSlug' => 'custom',
'findIfExists' => true,
]);
$this->assertEquals(self::STATUS_OK, $firstStatusCode);
$this->assertEquals(self::STATUS_BAD_REQUEST, $secondStatusCode);
}
/**
* @test
*/
public function createsNewShortUrlIfRequestedToFindButThereIsNoMatch(): void
{
[$firstStatusCode, ['shortCode' => $firstShortCode]] = $this->createShortUrl([
'longUrl' => 'https://www.alejandrocelaya.com',
]);
[$secondStatusCode, ['shortCode' => $secondShortCode]] = $this->createShortUrl([
'longUrl' => 'https://www.alejandrocelaya.com/projects',
'findIfExists' => true,
]);
$this->assertEquals(self::STATUS_OK, $firstStatusCode);
$this->assertEquals(self::STATUS_OK, $secondStatusCode);
$this->assertNotEquals($firstShortCode, $secondShortCode);
}
/**
* @return array {
* @var int $statusCode
* @var array $payload
* }
*/
private function createShortUrl(array $body = []): array
{
if (! isset($body['longUrl'])) {
$body['longUrl'] = 'https://app.shlink.io';
}
$resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body]);
$payload = $this->getJsonResponsePayload($resp);
return [$resp->getStatusCode(), $payload];
}
}

View File

@ -3,13 +3,11 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Middleware;
use GuzzleHttp\Exception\ClientException;
use Shlinkio\Shlink\Rest\Authentication\Plugin\ApiKeyHeaderPlugin;
use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use ShlinkioTest\Shlink\Common\ApiTest\ApiTestCase;
use function implode;
use function Shlinkio\Shlink\Common\json_decode;
use function sprintf;
class AuthenticationTest extends ApiTestCase
@ -19,21 +17,18 @@ class AuthenticationTest extends ApiTestCase
*/
public function authorizationErrorIsReturnedIfNoApiKeyIsSent()
{
try {
$this->callApi(self::METHOD_GET, '/short-codes');
} catch (ClientException $e) {
['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($e->getResponse());
$resp = $this->callApi(self::METHOD_GET, '/short-codes');
['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_UNAUTHORIZED, $e->getCode());
$this->assertEquals(RestUtils::INVALID_AUTHORIZATION_ERROR, $error);
$this->assertEquals(
sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
),
$message
);
}
$this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode());
$this->assertEquals(RestUtils::INVALID_AUTHORIZATION_ERROR, $error);
$this->assertEquals(
sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
),
$message
);
}
/**
@ -42,19 +37,16 @@ class AuthenticationTest extends ApiTestCase
*/
public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(string $apiKey)
{
try {
$this->callApi(self::METHOD_GET, '/short-codes', [
'headers' => [
ApiKeyHeaderPlugin::HEADER_NAME => $apiKey,
],
]);
} catch (ClientException $e) {
['error' => $error, 'message' => $message] = json_decode((string) $e->getResponse()->getBody());
$resp = $this->callApi(self::METHOD_GET, '/short-codes', [
'headers' => [
ApiKeyHeaderPlugin::HEADER_NAME => $apiKey,
],
]);
['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_UNAUTHORIZED, $e->getCode());
$this->assertEquals(RestUtils::INVALID_API_KEY_ERROR, $error);
$this->assertEquals('Provided API key does not exist or is invalid.', $message);
}
$this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode());
$this->assertEquals(RestUtils::INVALID_API_KEY_ERROR, $error);
$this->assertEquals('Provided API key does not exist or is invalid.', $message);
}
public function provideInvalidApiKeys(): array

View File

@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
@ -86,9 +87,7 @@ class CreateShortUrlActionTest extends TestCase
$this->urlShortener->urlToShortCode(
Argument::type(Uri::class),
Argument::type('array'),
null,
null,
'foo',
ShortUrlMeta::createFromRawData(['customSlug' => 'foo']),
Argument::cetera()
)->willThrow(NonUniqueSlugException::class)->shouldBeCalledOnce();

View File

@ -9,6 +9,7 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -91,10 +92,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase
return $argument;
}),
[],
null,
null,
null,
null
ShortUrlMeta::createEmpty()
)->willReturn(new ShortUrl(''));
$resp = $this->action->handle($request);