diff --git a/CHANGELOG.md b/CHANGELOG.md index edf73367..139a5877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/composer.json b/composer.json index cdda9028..52681a0e 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 296c0635..c40d75d1 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -30,6 +30,7 @@ return [ Option\TaskWorkerNumConfigOption::class, Option\WebWorkerNumConfigOption::class, Option\RedisServersConfigOption::class, + Option\ShortCodeLengthOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 5cf4f86f..165e0258 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -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, ], ]; diff --git a/docker/README.md b/docker/README.md index 76c47a09..699c1c75 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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" diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 9e10d419..6cf86434 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -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(), diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index be274ab6..ee8a6060 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -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" } } } diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 1f94f5a6..1cc67fd9 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -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], diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 28d192b1..7369f1f6 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -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, ]), ); diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index df1019b1..bcf00acb 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -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); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 7ab5ebbb..87399208 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -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) { diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index fa7a4acb..a03ccc3e 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -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' => [ diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 98d6a146..4af8844b 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -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; } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 27c8e624..3bba5c98 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -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; + } } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index 187ec66f..a71b4cc2 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -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; + } } diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 1d4f3b8d..7a304ad5 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -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' => [ diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 9aba83fa..e410dedb 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -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]); + } } diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 13c5ae14..fed2c662 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -44,6 +44,9 @@ class ShortUrlMetaTest extends TestCase ShortUrlMetaInputFilter::VALID_UNTIL => 500, ShortUrlMetaInputFilter::DOMAIN => 4, ]]; + yield [[ + ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3, + ]]; } /** @test */ diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index dc4c0e3b..b24ec1ee 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -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', + ], ], ]; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 61abb1b7..b104d81b 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -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]), diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php new file mode 100644 index 00000000..bcad748e --- /dev/null +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -0,0 +1,31 @@ +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)); + } +} diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php new file mode 100644 index 00000000..38d875d9 --- /dev/null +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -0,0 +1,54 @@ +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]; + } +}