diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 1a1673ac..976973b2 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -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); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 7a0000de..2502331a 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -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); } diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php index d7012bf1..2512fc44 100644 --- a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -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); } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 325b400f..ad3a6df7 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -19,68 +19,52 @@ 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 = $this->createInput(self::CUSTOM_SLUG, required: false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain() ->attach(new Validator\NotEmpty([ @@ -90,32 +74,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 = $this->createInput(self::PATH_PREFIX, required: false); + $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($this->createNumericInput(self::SHORT_CODE_LENGTH, required: false, min: MIN_SHORT_CODES_LENGTH)); + $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, required: false)); - // 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 = $this->createInput(self::DOMAIN, required: false); $domain->getValidatorChain()->attach(new Validation\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 = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); + $this->add($longUrlInput); - $title = $this->createInput(self::TITLE, false); + $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, required: false); + $deviceLongUrlsInput->getValidatorChain()->attach( + new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), + ); + $this->add($deviceLongUrlsInput); + + $validSince = $this->createInput(self::VALID_SINCE, required: false); + $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validSince); + + $validUntil = $this->createInput(self::VALID_UNTIL, required: false); + $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validUntil); + + $this->add($this->createNumericInput(self::MAX_VISITS, required: false)); + + $title = $this->createInput(self::TITLE, required: false); $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($this->createTagsInput(self::TAGS, required: false)); + $this->add($this->createBooleanInput(self::CRAWLABLE, required: false)); + + // 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($this->createInput(self::FORWARD_QUERY, required: false)); + + $apiKeyInput = $this->createInput(self::API_KEY, required: false); + $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $this->add($apiKeyInput); } private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain