Allow providing device long URLs during short URL edition

This commit is contained in:
Alejandro Celaya 2023-01-14 15:44:12 +01:00
parent 1447687ebe
commit 822652cac3
6 changed files with 140 additions and 134 deletions

View File

@ -55,6 +55,9 @@ class ShortUrl extends AbstractEntity
{ {
} }
/**
* @deprecated This should not be allowed
*/
public static function createEmpty(): self public static function createEmpty(): self
{ {
return self::create(ShortUrlCreation::createEmpty()); return self::create(ShortUrlCreation::createEmpty());
@ -226,34 +229,34 @@ class ShortUrl extends AbstractEntity
?ShortUrlRelationResolverInterface $relationResolver = null, ?ShortUrlRelationResolverInterface $relationResolver = null,
): void { ): void {
if ($shortUrlEdit->validSinceWasProvided()) { if ($shortUrlEdit->validSinceWasProvided()) {
$this->validSince = $shortUrlEdit->validSince(); $this->validSince = $shortUrlEdit->validSince;
} }
if ($shortUrlEdit->validUntilWasProvided()) { if ($shortUrlEdit->validUntilWasProvided()) {
$this->validUntil = $shortUrlEdit->validUntil(); $this->validUntil = $shortUrlEdit->validUntil;
} }
if ($shortUrlEdit->maxVisitsWasProvided()) { if ($shortUrlEdit->maxVisitsWasProvided()) {
$this->maxVisits = $shortUrlEdit->maxVisits(); $this->maxVisits = $shortUrlEdit->maxVisits;
} }
if ($shortUrlEdit->longUrlWasProvided()) { if ($shortUrlEdit->longUrlWasProvided()) {
$this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl; $this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl;
} }
if ($shortUrlEdit->tagsWereProvided()) { if ($shortUrlEdit->tagsWereProvided()) {
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags);
} }
if ($shortUrlEdit->crawlableWasProvided()) { if ($shortUrlEdit->crawlableWasProvided()) {
$this->crawlable = $shortUrlEdit->crawlable(); $this->crawlable = $shortUrlEdit->crawlable;
} }
if ( if (
$this->title === null $this->title === null
|| $shortUrlEdit->titleWasProvided() || $shortUrlEdit->titleWasProvided()
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
) { ) {
$this->title = $shortUrlEdit->title(); $this->title = $shortUrlEdit->title;
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
} }
if ($shortUrlEdit->forwardQueryWasProvided()) { if ($shortUrlEdit->forwardQueryWasProvided()) {
$this->forwardQuery = $shortUrlEdit->forwardQuery(); $this->forwardQuery = $shortUrlEdit->forwardQuery;
} }
} }

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Shlinkio\Shlink\Core\Model\DeviceType;
use function array_values;
use function Functional\map;
use function trim;
final class DeviceLongUrlPair
{
private function __construct(public readonly DeviceType $deviceType, public readonly string $longUrl)
{
}
public static function fromRawTypeAndLongUrl(string $type, string $longUrl): self
{
return new self(DeviceType::from($type), trim($longUrl));
}
/**
* @param array<string, string> $map
* @return self[]
*/
public static function fromMapToList(array $map): array
{
return array_values(map(
$map,
fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl),
));
}
}

View File

@ -6,17 +6,14 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\map;
use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter; use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function trim;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -24,7 +21,7 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
{ {
/** /**
* @param string[] $tags * @param string[] $tags
* @param array{DeviceType, string}[] $deviceLongUrls * @param DeviceLongUrlPair[] $deviceLongUrls
*/ */
private function __construct( private function __construct(
public readonly string $longUrl, public readonly string $longUrl,
@ -46,6 +43,9 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
) { ) {
} }
/**
* @deprecated This should not be allowed
*/
public static function createEmpty(): self public static function createEmpty(): self
{ {
return new self(''); return new self('');
@ -63,9 +63,8 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
return new self( return new self(
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
deviceLongUrls: map( deviceLongUrls: DeviceLongUrlPair::fromMapToList(
$inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
static fn (string $longUrl, string $deviceType) => [DeviceType::from($deviceType), trim($longUrl)],
), ),
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
@ -89,22 +88,22 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
public function withResolvedTitle(string $title): self public function withResolvedTitle(string $title): self
{ {
return new self( return new self(
$this->longUrl, longUrl: $this->longUrl,
$this->deviceLongUrls, deviceLongUrls: $this->deviceLongUrls,
$this->validSince, validSince: $this->validSince,
$this->validUntil, validUntil: $this->validUntil,
$this->customSlug, customSlug: $this->customSlug,
$this->maxVisits, maxVisits: $this->maxVisits,
$this->findIfExists, findIfExists: $this->findIfExists,
$this->domain, domain: $this->domain,
$this->shortCodeLength, shortCodeLength: $this->shortCodeLength,
$this->validateUrl, validateUrl: $this->validateUrl,
$this->apiKey, apiKey: $this->apiKey,
$this->tags, tags: $this->tags,
$title, title: $title,
true, titleWasAutoResolved: true,
$this->crawlable, crawlable: $this->crawlable,
$this->forwardQuery, forwardQuery: $this->forwardQuery,
); );
} }

View File

@ -16,77 +16,93 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface final class ShortUrlEdition implements TitleResolutionModelInterface
{ {
private bool $longUrlPropWasProvided = false; /**
private ?string $longUrl = null; * @param string[] $tags
private bool $validSincePropWasProvided = false; */
private ?Chronos $validSince = null; private function __construct(
private bool $validUntilPropWasProvided = false; private readonly bool $longUrlPropWasProvided = false,
private ?Chronos $validUntil = null; public readonly ?string $longUrl = null,
private bool $maxVisitsPropWasProvided = false; public readonly array $deviceLongUrls = [],
private ?int $maxVisits = null; private readonly bool $validSincePropWasProvided = false,
private bool $tagsPropWasProvided = false; public readonly ?Chronos $validSince = null,
private array $tags = []; private readonly bool $validUntilPropWasProvided = false,
private bool $titlePropWasProvided = false; public readonly ?Chronos $validUntil = null,
private ?string $title = null; private readonly bool $maxVisitsPropWasProvided = false,
private bool $titleWasAutoResolved = false; public readonly ?int $maxVisits = null,
private bool $validateUrl = false; private readonly bool $tagsPropWasProvided = false,
private bool $crawlablePropWasProvided = false; public readonly array $tags = [],
private bool $crawlable = false; private readonly bool $titlePropWasProvided = false,
private bool $forwardQueryPropWasProvided = false; public readonly ?string $title = null,
private bool $forwardQuery = true; public readonly bool $titleWasAutoResolved = false,
public readonly bool $validateUrl = false,
private function __construct() private readonly bool $crawlablePropWasProvided = false,
{ public readonly bool $crawlable = false,
private readonly bool $forwardQueryPropWasProvided = false,
public readonly bool $forwardQuery = true,
) {
} }
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public static function fromRawData(array $data): self public static function fromRawData(array $data): self
{
$instance = new self();
$instance->validateAndInit($data);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $data): void
{ {
$inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data);
if (! $inputFilter->isValid()) { if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
$this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data); return new self(
$this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data),
$this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); deviceLongUrls: DeviceLongUrlPair::fromMapToList(
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data); ),
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data); validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data),
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data),
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); maxVisitsPropWasProvided: array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data),
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); tagsPropWasProvided: array_key_exists(ShortUrlInputFilter::TAGS, $data),
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data),
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data),
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data),
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
);
} }
public function longUrl(): ?string public function withResolvedTitle(string $title): self
{ {
return $this->longUrl; return new self(
longUrlPropWasProvided: $this->longUrlPropWasProvided,
longUrl: $this->longUrl,
validSincePropWasProvided: $this->validSincePropWasProvided,
validSince: $this->validSince,
validUntilPropWasProvided: $this->validUntilPropWasProvided,
validUntil: $this->validUntil,
maxVisitsPropWasProvided: $this->maxVisitsPropWasProvided,
maxVisits: $this->maxVisits,
tagsPropWasProvided: $this->tagsPropWasProvided,
tags: $this->tags,
titlePropWasProvided: $this->titlePropWasProvided,
title: $title,
titleWasAutoResolved: true,
validateUrl: $this->validateUrl,
crawlablePropWasProvided: $this->crawlablePropWasProvided,
crawlable: $this->crawlable,
forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided,
forwardQuery: $this->forwardQuery,
);
} }
public function getLongUrl(): string public function getLongUrl(): string
{ {
return $this->longUrl() ?? ''; return $this->longUrl ?? '';
} }
public function longUrlWasProvided(): bool public function longUrlWasProvided(): bool
@ -94,54 +110,26 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
return $this->longUrlPropWasProvided && $this->longUrl !== null; return $this->longUrlPropWasProvided && $this->longUrl !== null;
} }
public function validSince(): ?Chronos
{
return $this->validSince;
}
public function validSinceWasProvided(): bool public function validSinceWasProvided(): bool
{ {
return $this->validSincePropWasProvided; return $this->validSincePropWasProvided;
} }
public function validUntil(): ?Chronos
{
return $this->validUntil;
}
public function validUntilWasProvided(): bool public function validUntilWasProvided(): bool
{ {
return $this->validUntilPropWasProvided; return $this->validUntilPropWasProvided;
} }
public function maxVisits(): ?int
{
return $this->maxVisits;
}
public function maxVisitsWasProvided(): bool public function maxVisitsWasProvided(): bool
{ {
return $this->maxVisitsPropWasProvided; return $this->maxVisitsPropWasProvided;
} }
/**
* @return string[]
*/
public function tags(): array
{
return $this->tags;
}
public function tagsWereProvided(): bool public function tagsWereProvided(): bool
{ {
return $this->tagsPropWasProvided; return $this->tagsPropWasProvided;
} }
public function title(): ?string
{
return $this->title;
}
public function titleWasProvided(): bool public function titleWasProvided(): bool
{ {
return $this->titlePropWasProvided; return $this->titlePropWasProvided;
@ -157,35 +145,16 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
return $this->titleWasAutoResolved; return $this->titleWasAutoResolved;
} }
public function withResolvedTitle(string $title): self
{
$copy = clone $this;
$copy->title = $title;
$copy->titleWasAutoResolved = true;
return $copy;
}
public function doValidateUrl(): bool public function doValidateUrl(): bool
{ {
return $this->validateUrl; return $this->validateUrl;
} }
public function crawlable(): bool
{
return $this->crawlable;
}
public function crawlableWasProvided(): bool public function crawlableWasProvided(): bool
{ {
return $this->crawlablePropWasProvided; return $this->crawlablePropWasProvided;
} }
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
public function forwardQueryWasProvided(): bool public function forwardQueryWasProvided(): bool
{ {
return $this->forwardQueryPropWasProvided; return $this->forwardQueryPropWasProvided;

View File

@ -78,7 +78,7 @@ class ShortUrlInputFilter extends InputFilter
$this->add($longUrlInput); $this->add($longUrlInput);
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
$deviceLongUrlsInput->getValidatorChain()->attach( $deviceLongUrlsInput->getValidatorChain()->attach( // TODO Extract callback to own validator
new Validator\Callback(function (mixed $value) use ($notEmptyValidator): bool { new Validator\Callback(function (mixed $value) use ($notEmptyValidator): bool {
if (! is_array($value)) { if (! is_array($value)) {
// TODO Set proper error: Not array // TODO Set proper error: Not array

View File

@ -73,10 +73,10 @@ class ShortUrlServiceTest extends TestCase
); );
self::assertSame($shortUrl, $result); self::assertSame($shortUrl, $result);
self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince());
self::assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil());
self::assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits());
self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl());
} }
public function provideShortUrlEdits(): iterable public function provideShortUrlEdits(): iterable