Merge pull request #2020 from acelaya-forks/feature/path-prefix

Feature/path prefix
This commit is contained in:
Alejandro Celaya 2024-02-21 19:41:19 +01:00 committed by GitHub
commit 145d4eaaed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 203 additions and 133 deletions

View file

@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag.
* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo.
* [#1884](https://github.com/shlinkio/shlink/issues/1884) Allow a path prefix to be provided during short URL creation.
This can be useful to let Shlink generate partially random URLs, but with a known prefix.
Path prefixes are validated and filtered taking multi-segment slugs into consideration, which means slashes are replaced with dashes as long as multi-segment slugs are disabled.
### Changed
* [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware.

View file

@ -42,7 +42,7 @@
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "dev-main#b9a6bd5 as 6.0",
"shlinkio/shlink-common": "dev-main#3e5bf59 as 6.0",
"shlinkio/shlink-config": "dev-main#a43b380 as 3.0",
"shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0",
"shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3",

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Application;
use Mezzio\Container;
use Psr\Http\Client\ClientInterface;
@ -12,12 +13,14 @@ use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface;
use Symfony\Component\Filesystem\Filesystem;
return [
'dependencies' => [
'factories' => [
PSR7Worker::class => ConfigAbstractFactory::class,
Filesystem::class => InvokableFactory::class,
],
'delegators' => [

View file

@ -303,6 +303,10 @@
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"pathPrefix": {
"description": "A prefix that will be prepended to provided custom slug or auto-generated short code",
"type": "string"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
@ -382,6 +386,7 @@
"validSince",
"validUntil",
"customSlug",
"pathPrefix",
"maxVisits",
"findIfExists",
"domain"

View file

@ -70,6 +70,12 @@ class CreateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'max-visits',
'm',
@ -138,7 +144,6 @@ class CreateShortUrlCommand extends Command
$explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
@ -147,8 +152,9 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'),
ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,

View file

@ -5,12 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Validation;
use Laminas\InputFilter\InputFilter;
use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
class DomainRedirectsInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
public const DOMAIN = 'domain';
public const BASE_URL_REDIRECT = 'baseUrlRedirect';
public const REGULAR_404_REDIRECT = 'regular404Redirect';
@ -32,12 +31,12 @@ class DomainRedirectsInputFilter extends InputFilter
private function initializeInputs(): void
{
$domain = $this->createInput(self::DOMAIN);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$domain = InputFactory::basic(self::DOMAIN, required: true);
$domain->getValidatorChain()->attach(new HostAndPortValidator());
$this->add($domain);
$this->add($this->createInput(self::BASE_URL_REDIRECT, false));
$this->add($this->createInput(self::REGULAR_404_REDIRECT, false));
$this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false));
$this->add(InputFactory::basic(self::BASE_URL_REDIRECT));
$this->add(InputFactory::basic(self::REGULAR_404_REDIRECT));
$this->add(InputFactory::basic(self::INVALID_SHORT_URL_REDIRECT));
}
}

View file

@ -33,6 +33,7 @@ use function Shlinkio\Shlink\Core\enumValues;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
class ShortUrl extends AbstractEntity
{
@ -100,9 +101,10 @@ class ShortUrl extends AbstractEntity
$instance->maxVisits = $creation->maxVisits;
$instance->customSlugWasProvided = $creation->hasCustomSlug();
$instance->shortCodeLength = $creation->shortCodeLength;
$instance->shortCode = $creation->customSlug ?? generateRandomShortCode(
$instance->shortCodeLength,
$creation->shortUrlMode,
$instance->shortCode = sprintf(
'%s%s',
$creation->pathPrefix ?? '',
$creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength, $creation->shortUrlMode),
);
$instance->domain = $relationResolver->resolveDomain($creation->domain);
$instance->authorApiKey = $creation->apiKey;

View file

@ -18,39 +18,39 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final class ShortUrlCreation implements TitleResolutionModelInterface
final readonly class ShortUrlCreation implements TitleResolutionModelInterface
{
/**
* @param string[] $tags
* @param DeviceLongUrlPair[] $deviceLongUrls
*/
private function __construct(
public readonly string $longUrl,
public readonly ShortUrlMode $shortUrlMode,
public readonly array $deviceLongUrls = [],
public readonly ?Chronos $validSince = null,
public readonly ?Chronos $validUntil = null,
public readonly ?string $customSlug = null,
public readonly ?int $maxVisits = null,
public readonly bool $findIfExists = false,
public readonly ?string $domain = null,
public readonly int $shortCodeLength = 5,
public readonly ?ApiKey $apiKey = null,
public readonly array $tags = [],
public readonly ?string $title = null,
public readonly bool $titleWasAutoResolved = false,
public readonly bool $crawlable = false,
public readonly bool $forwardQuery = true,
public string $longUrl,
public ShortUrlMode $shortUrlMode,
public array $deviceLongUrls = [],
public ?Chronos $validSince = null,
public ?Chronos $validUntil = null,
public ?string $customSlug = null,
public ?string $pathPrefix = null,
public ?int $maxVisits = null,
public bool $findIfExists = false,
public ?string $domain = null,
public int $shortCodeLength = 5,
public ?ApiKey $apiKey = null,
public array $tags = [],
public ?string $title = null,
public bool $titleWasAutoResolved = false,
public bool $crawlable = false,
public bool $forwardQuery = true,
) {
}
/**
* @throws ValidationException
*/
public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self
public static function fromRawData(array $data, UrlShortenerOptions $options = new UrlShortenerOptions()): self
{
$options = $options ?? new UrlShortenerOptions();
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options);
$inputFilter = ShortUrlInputFilter::forCreation($data, $options);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
@ -66,6 +66,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG),
pathPrefix: $inputFilter->getValue(ShortUrlInputFilter::PATH_PREFIX),
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false,
domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN),
@ -90,6 +91,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
validSince: $this->validSince,
validUntil: $this->validUntil,
customSlug: $this->customSlug,
pathPrefix: $this->pathPrefix,
maxVisits: $this->maxVisits,
findIfExists: $this->findIfExists,
domain: $this->domain,

View file

@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface
final readonly class ShortUrlEdition implements TitleResolutionModelInterface
{
/**
* @param string[] $tags
@ -23,25 +23,25 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
* @param DeviceType[] $devicesToRemove
*/
private function __construct(
private readonly bool $longUrlPropWasProvided = false,
public readonly ?string $longUrl = null,
public readonly array $deviceLongUrls = [],
public readonly array $devicesToRemove = [],
private readonly bool $validSincePropWasProvided = false,
public readonly ?Chronos $validSince = null,
private readonly bool $validUntilPropWasProvided = false,
public readonly ?Chronos $validUntil = null,
private readonly bool $maxVisitsPropWasProvided = false,
public readonly ?int $maxVisits = null,
private readonly bool $tagsPropWasProvided = false,
public readonly array $tags = [],
private readonly bool $titlePropWasProvided = false,
public readonly ?string $title = null,
public readonly bool $titleWasAutoResolved = false,
private readonly bool $crawlablePropWasProvided = false,
public readonly bool $crawlable = false,
private readonly bool $forwardQueryPropWasProvided = false,
public readonly bool $forwardQuery = true,
private bool $longUrlPropWasProvided = false,
public ?string $longUrl = null,
public array $deviceLongUrls = [],
public array $devicesToRemove = [],
private bool $validSincePropWasProvided = false,
public ?Chronos $validSince = null,
private bool $validUntilPropWasProvided = false,
public ?Chronos $validUntil = null,
private bool $maxVisitsPropWasProvided = false,
public ?int $maxVisits = null,
private bool $tagsPropWasProvided = false,
public array $tags = [],
private bool $titlePropWasProvided = false,
public ?string $title = null,
public bool $titleWasAutoResolved = false,
private bool $crawlablePropWasProvided = false,
public bool $crawlable = false,
private bool $forwardQueryPropWasProvided = false,
public bool $forwardQuery = true,
) {
}
@ -50,7 +50,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
*/
public static function fromRawData(array $data): self
{
$inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data);
$inputFilter = ShortUrlInputFilter::forEdition($data);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}

View file

@ -12,9 +12,9 @@ use function str_replace;
use function strtolower;
use function trim;
class CustomSlugFilter implements FilterInterface
readonly class CustomSlugFilter implements FilterInterface
{
public function __construct(private readonly UrlShortenerOptions $options)
public function __construct(private UrlShortenerOptions $options)
{
}
@ -25,9 +25,8 @@ class CustomSlugFilter implements FilterInterface
}
$value = $this->options->isLooseMode() ? strtolower($value) : $value;
return (match ($this->options->multiSegmentSlugsEnabled) {
true => trim(str_replace(' ', '-', $value), '/'),
false => str_replace([' ', '/'], '-', $value),
});
return $this->options->multiSegmentSlugsEnabled
? trim(str_replace(' ', '-', $value), '/')
: str_replace([' ', '/'], '-', $value);
}
}

View file

@ -8,7 +8,8 @@ use DateTimeInterface;
use Laminas\Filter;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -19,68 +20,50 @@ use function substr;
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
/**
* @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl.
* Make it also dynamically add the relevant fields
*/
class ShortUrlInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
public const VALID_SINCE = 'validSince';
public const VALID_UNTIL = 'validUntil';
// Fields for creation only
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const CUSTOM_SLUG = 'customSlug';
public const MAX_VISITS = 'maxVisits';
public const PATH_PREFIX = 'pathPrefix';
public const FIND_IF_EXISTS = 'findIfExists';
public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength';
// Fields for creation and edition
public const LONG_URL = 'longUrl';
public const DEVICE_LONG_URLS = 'deviceLongUrls';
public const API_KEY = 'apiKey';
public const TAGS = 'tags';
public const VALID_SINCE = 'validSince';
public const VALID_UNTIL = 'validUntil';
public const MAX_VISITS = 'maxVisits';
public const TITLE = 'title';
public const TAGS = 'tags';
public const CRAWLABLE = 'crawlable';
public const FORWARD_QUERY = 'forwardQuery';
public const API_KEY = 'apiKey';
private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options)
public static function forCreation(array $data, UrlShortenerOptions $options): self
{
$this->initialize($requireLongUrl, $options);
$this->setData($data);
$instance = new self();
$instance->initializeForCreation($options);
$instance->setData($data);
return $instance;
}
public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self
public static function forEdition(array $data): self
{
return new self($data, true, $options);
$instance = new self();
$instance->initializeForEdition();
$instance->setData($data);
return $instance;
}
public static function withNonRequiredLongUrl(array $data): self
private function initializeForCreation(UrlShortenerOptions $options): void
{
return new self($data, false, new UrlShortenerOptions());
}
private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void
{
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
$this->add($longUrlInput);
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
$deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)),
);
$this->add($deviceLongUrlsInput);
$validSince = $this->createInput(self::VALID_SINCE, false);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validSince);
$validUntil = $this->createInput(self::VALID_UNTIL, false);
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validUntil);
// The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value
// is with setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
// is with setContinueIfEmpty(true)
$customSlug = InputFactory::basic(self::CUSTOM_SLUG)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
$customSlug->getValidatorChain()
->attach(new Validator\NotEmpty([
@ -90,32 +73,62 @@ class ShortUrlInputFilter extends InputFilter
->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($customSlug);
$this->add($this->createNumericInput(self::MAX_VISITS, false));
$this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, false, MIN_SHORT_CODES_LENGTH));
// The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into
// consideration if multi-segment slugs are enabled or not.
// The only difference is that empty values are allowed here.
$pathPrefix = InputFactory::basic(self::PATH_PREFIX);
$pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options));
$pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($pathPrefix);
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
$this->add(InputFactory::numeric(self::SHORT_CODE_LENGTH, min: MIN_SHORT_CODES_LENGTH));
$this->add(InputFactory::boolean(self::FIND_IF_EXISTS));
// This cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null.
// Defining them as boolean will make null fall back to false, which is not the desired behavior.
$this->add($this->createInput(self::FORWARD_QUERY, false));
$domain = $this->createInput(self::DOMAIN, false);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$domain = InputFactory::basic(self::DOMAIN);
$domain->getValidatorChain()->attach(new HostAndPortValidator());
$this->add($domain);
$apiKeyInput = $this->createInput(self::API_KEY, false);
$apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
$this->add($apiKeyInput);
$this->initializeForEdition(requireLongUrl: true);
}
$this->add($this->createTagsInput(self::TAGS, false));
private function initializeForEdition(bool $requireLongUrl = false): void
{
$longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
$this->add($longUrlInput);
$title = $this->createInput(self::TITLE, false);
$deviceLongUrlsInput = InputFactory::basic(self::DEVICE_LONG_URLS);
$deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)),
);
$this->add($deviceLongUrlsInput);
$validSince = InputFactory::basic(self::VALID_SINCE);
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validSince);
$validUntil = InputFactory::basic(self::VALID_UNTIL);
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
$this->add($validUntil);
$this->add(InputFactory::numeric(self::MAX_VISITS));
$title = InputFactory::basic(self::TITLE);
$title->getFilterChain()->attach(new Filter\Callback(
static fn (?string $value) => $value === null ? $value : substr($value, 0, 512),
));
$this->add($title);
$this->add($this->createBooleanInput(self::CRAWLABLE, false));
$this->add(InputFactory::tags(self::TAGS));
$this->add(InputFactory::boolean(self::CRAWLABLE));
// This cannot be defined as a boolean inputs, because it can actually have 3 values: true, false and null.
// Defining them as boolean will make null fall back to false, which is not the desired behavior.
$this->add(InputFactory::basic(self::FORWARD_QUERY));
$apiKeyInput = InputFactory::basic(self::API_KEY);
$apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
$this->add($apiKeyInput);
}
private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
@ -15,8 +15,6 @@ use function Shlinkio\Shlink\Core\enumValues;
class ShortUrlsParamsInputFilter extends InputFilter
{
use Validation\InputFactoryTrait;
public const PAGE = 'page';
public const SEARCH_TERM = 'searchTerm';
public const TAGS = 'tags';
@ -36,26 +34,26 @@ class ShortUrlsParamsInputFilter extends InputFilter
private function initialize(): void
{
$this->add($this->createDateInput(self::START_DATE, false));
$this->add($this->createDateInput(self::END_DATE, false));
$this->add(InputFactory::date(self::START_DATE));
$this->add(InputFactory::date(self::END_DATE));
$this->add($this->createInput(self::SEARCH_TERM, false));
$this->add(InputFactory::basic(self::SEARCH_TERM));
$this->add($this->createNumericInput(self::PAGE, false));
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS));
$this->add(InputFactory::numeric(self::PAGE));
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
$this->add($this->createTagsInput(self::TAGS, false));
$this->add(InputFactory::tags(self::TAGS));
$tagsMode = $this->createInput(self::TAGS_MODE, false);
$tagsMode = InputFactory::basic(self::TAGS_MODE);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT,
]));
$this->add($tagsMode);
$this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false));
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false));
$this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED));
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
}
}

View file

@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\DeviceType;
@ -91,6 +92,27 @@ class ShortUrlTest extends TestCase
yield from array_map(fn (int $value) => [$value, $value], range(4, 10));
}
#[Test]
#[TestWith([null, '', 5])]
#[TestWith(['foo bar/', 'foo-bar-', 13])]
public function shortCodesHaveExpectedPrefix(
?string $pathPrefix,
string $expectedPrefix,
int $expectedShortCodeLength,
): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://longUrl',
ShortUrlInputFilter::SHORT_CODE_LENGTH => 5,
ShortUrlInputFilter::PATH_PREFIX => $pathPrefix,
]));
$shortCode = $shortUrl->getShortCode();
if (strlen($expectedPrefix) > 0) {
self::assertStringStartsWith($expectedPrefix, $shortCode);
}
self::assertEquals($expectedShortCodeLength, strlen($shortCode));
}
#[Test]
public function deviceLongUrlsAreUpdated(): void
{

View file

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function array_map;
@ -339,6 +340,21 @@ class CreateShortUrlTest extends ApiTestCase
self::assertNull($payload['title']);
}
#[Test]
#[TestWith([null])]
#[TestWith(['my-custom-slug'])]
public function prefixCanBeSet(?string $customSlug): void
{
[$statusCode, $payload] = $this->createShortUrl([
'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557',
'pathPrefix' => 'foo/b ar-baz',
'customSlug' => $customSlug,
]);
self::assertEquals(self::STATUS_OK, $statusCode);
self::assertStringStartsWith('foo-b--ar-baz', $payload['shortCode']);
}
/**
* @return array{int, array}
*/