Merge pull request #660 from acelaya-forks/feature/short-codes-length

Feature/short codes length
This commit is contained in:
Alejandro Celaya 2020-02-18 20:42:37 +01:00 committed by GitHub
commit f53fa5c90f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 208 additions and 14 deletions

View File

@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### Added
* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server.
* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis.
#### Changed

View File

@ -49,7 +49,7 @@
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.7.0",
"shlinkio/shlink-event-dispatcher": "^1.3",
"shlinkio/shlink-installer": "^4.1.0",
"shlinkio/shlink-installer": "^4.2.0",
"shlinkio/shlink-ip-geolocation": "^1.3.1",
"symfony/console": "^5.0",
"symfony/filesystem": "^5.0",

View File

@ -30,6 +30,7 @@ return [
Option\TaskWorkerNumConfigOption::class,
Option\WebWorkerNumConfigOption::class,
Option\RedisServersConfigOption::class,
Option\ShortCodeLengthOption::class,
],
'installation_commands' => [

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
return [
'url_shortener' => [
@ -11,6 +13,7 @@ return [
],
'validate_url' => false,
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
],
];

View File

@ -113,6 +113,7 @@ This is the complete list of supported env vars:
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
@ -146,6 +147,7 @@ docker run \
-e WEB_WORKER_NUM=64 \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
shlinkio/shlink:stable
```
@ -170,6 +172,7 @@ The whole configuration should have this format, but it can be split into multip
"base_path": "/my-campaign",
"web_worker_num": 64,
"task_worker_num": 32,
"default_short_codes_length": 6,
"redis_servers": [
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"

View File

@ -11,6 +11,9 @@ use function explode;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
$helper = new class {
private const DB_DRIVERS_MAP = [
'mysql' => 'pdo_mysql',
@ -70,6 +73,12 @@ $helper = new class {
$redisServers = env('REDIS_SERVERS');
return $redisServers === null ? null : ['servers' => $redisServers];
}
public function getDefaultShortCodesLength(): int
{
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
}
};
return [
@ -96,6 +105,7 @@ return [
],
'validate_url' => (bool) env('VALIDATE_URLS', false),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

View File

@ -243,6 +243,10 @@
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
}
}
}

View File

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

View File

@ -30,12 +30,14 @@ class GenerateShortUrlCommand extends Command
private UrlShortenerInterface $urlShortener;
private array $domainConfig;
private int $defaultShortCodeLength;
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
{
parent::__construct();
$this->urlShortener = $urlShortener;
$this->domainConfig = $domainConfig;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
protected function configure(): void
@ -87,6 +89,12 @@ class GenerateShortUrlCommand extends Command
'd',
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'shortCodeLength',
'l',
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --customSlug was provided).',
);
}
@ -117,6 +125,7 @@ class GenerateShortUrlCommand extends Command
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
try {
$shortUrl = $this->urlShortener->urlToShortCode(
@ -129,6 +138,7 @@ class GenerateShortUrlCommand extends Command
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
]),
);

View File

@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);

View File

@ -10,7 +10,10 @@ use PUGX\Shortid\Factory as ShortIdFactory;
use function sprintf;
function generateRandomShortCode(int $length = 5): string
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
function generateRandomShortCode(int $length): string
{
static $shortIdFactory;
if ($shortIdFactory === null) {

View File

@ -32,6 +32,7 @@ class SimplifiedConfigParser
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View File

@ -34,6 +34,7 @@ class ShortUrl extends AbstractEntity
private ?int $maxVisits = null;
private ?Domain $domain;
private bool $customSlugWasProvided;
private int $shortCodeLength;
public function __construct(
string $longUrl,
@ -50,7 +51,8 @@ class ShortUrl extends AbstractEntity
$this->validUntil = $meta->getValidUntil();
$this->maxVisits = $meta->getMaxVisits();
$this->customSlugWasProvided = $meta->hasCustomSlug();
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode();
$this->shortCodeLength = $meta->getShortCodeLength();
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
}
@ -119,7 +121,7 @@ class ShortUrl extends AbstractEntity
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
}
$this->shortCode = generateRandomShortCode();
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
return $this;
}

View File

@ -11,6 +11,8 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\parseDateField;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlMeta
{
private bool $validSincePropWasProvided = false;
@ -22,6 +24,7 @@ final class ShortUrlMeta
private ?int $maxVisits = null;
private ?bool $findIfExists = null;
private ?string $domain = null;
private int $shortCodeLength = 5;
// Force named constructors
private function __construct()
@ -58,11 +61,20 @@ final class ShortUrlMeta
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
$inputFilter,
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
) ?? DEFAULT_SHORT_CODES_LENGTH;
}
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
}
public function getValidSince(): ?Chronos
@ -119,4 +131,9 @@ final class ShortUrlMeta
{
return $this->domain;
}
public function getShortCodeLength(): int
{
return $this->shortCodeLength;
}
}

View File

@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use DateTime;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
class ShortUrlMetaInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
@ -19,6 +22,7 @@ class ShortUrlMetaInputFilter extends InputFilter
public const MAX_VISITS = 'maxVisits';
public const FIND_IF_EXISTS = 'findIfExists';
public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public function __construct(array $data)
{
@ -40,10 +44,8 @@ class ShortUrlMetaInputFilter extends InputFilter
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter());
$this->add($customSlug);
$maxVisits = $this->createInput(self::MAX_VISITS, false);
$maxVisits->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
$this->add($maxVisits);
$this->add($this->createPositiveNumberInput(self::MAX_VISITS));
$this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH));
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
@ -51,4 +53,13 @@ class ShortUrlMetaInputFilter extends InputFilter
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain);
}
private function createPositiveNumberInput(string $name, int $min = 1): Input
{
$input = $this->createInput($name, false);
$input->getValidatorChain()->attach(new Validator\Digits())
->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true]));
return $input;
}
}

View File

@ -57,6 +57,7 @@ class SimplifiedConfigParserTest extends TestCase
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
];
$expected = [
'app_options' => [
@ -84,6 +85,7 @@ class SimplifiedConfigParserTest extends TestCase
'http://my-api.com/api/v2.3/notify',
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
],
'delete_short_urls' => [

View File

@ -8,6 +8,13 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function Functional\map;
use function range;
use function strlen;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
class ShortUrlTest extends TestCase
{
@ -48,4 +55,23 @@ class ShortUrlTest extends TestCase
$this->assertNotEquals($firstShortCode, $secondShortCode);
}
/**
* @test
* @dataProvider provideLengths
*/
public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void
{
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(
[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length],
));
$this->assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
}
public function provideLengths(): iterable
{
yield [null, DEFAULT_SHORT_CODES_LENGTH];
yield from map(range(4, 10), fn (int $value) => [$value, $value]);
}
}

View File

@ -44,6 +44,9 @@ class ShortUrlMetaTest extends TestCase
ShortUrlMetaInputFilter::VALID_UNTIL => 500,
ShortUrlMetaInputFilter::DOMAIN => 4,
]];
yield [[
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3,
]];
}
/** @test */

View File

@ -38,6 +38,7 @@ return [
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
],
],
@ -75,6 +76,9 @@ return [
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
'config.url_shortener.default_short_codes_length',
],
],
];

View File

@ -13,7 +13,11 @@ return [
Action\HealthAction::getRouteDef(),
// Short codes
Action\ShortUrl\CreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware, $dropDomainMiddleware]),
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
class DefaultShortCodesLengthMiddleware implements MiddlewareInterface
{
private int $defaultShortCodesLength;
public function __construct(int $defaultShortCodesLength)
{
$this->defaultShortCodesLength = $defaultShortCodesLength;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$body = $request->getParsedBody();
if (! isset($body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH])) {
$body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength;
}
return $handler->handle($request->withParsedBody($body));
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Middleware\ShortUrl;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware;
class DefaultShortCodesLengthMiddlewareTest extends TestCase
{
private DefaultShortCodesLengthMiddleware $middleware;
private ObjectProphecy $handler;
public function setUp(): void
{
$this->handler = $this->prophesize(RequestHandlerInterface::class);
$this->middleware = new DefaultShortCodesLengthMiddleware(8);
}
/**
* @test
* @dataProvider provideBodies
*/
public function defaultValueIsInjectedInBodyWhenNotProvided(array $body, int $expectedLength): void
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody($body);
$handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) {
$parsedBody = $req->getParsedBody();
Assert::assertArrayHasKey(ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, $parsedBody);
Assert::assertEquals($expectedLength, $parsedBody[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH]);
return $req;
}))->willReturn(new Response());
$this->middleware->process($request, $this->handler->reveal());
$handle->shouldHaveBeenCalledOnce();
}
public function provideBodies(): iterable
{
yield 'value provided' => [[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 6], 6];
yield 'value not provided' => [[], 8];
}
}