mirror of
https://github.com/shlinkio/shlink.git
synced 2024-12-27 01:11:39 -06:00
Merge pull request #343 from acelaya/feature/allow-check-duplicates
Feature/allow check duplicates
This commit is contained in:
commit
79c132219b
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
10
CHANGELOG.md
10
CHANGELOG.md
@ -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
|
||||
|
@ -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));
|
||||
}
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
@ -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'],
|
||||
],
|
||||
|
||||
];
|
@ -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'] ?? []);
|
||||
});
|
||||
|
@ -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'));
|
||||
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
35
module/Common/src/Validation/InputFactoryTrait.php
Normal file
35
module/Common/src/Validation/InputFactoryTrait.php
Normal 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;
|
||||
}
|
||||
}
|
31
module/Common/src/Validation/SluggerFilter.php
Normal file
31
module/Common/src/Validation/SluggerFilter.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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'],
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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('&/(');
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
193
module/Rest/test-api/Action/CreateShortUrlActionTest.php
Normal file
193
module/Rest/test-api/Action/CreateShortUrlActionTest.php
Normal 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];
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user