Use valinor to validate and map short URL list params

This commit is contained in:
Alejandro Celaya committed 2026-04-17 18:19:13 +02:00
1 parent 14ef24a079
commit 35257c5e3e
13 files changed
+181 -281

No files matched your search

+8
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use function Shlinkio\Shlink\Common\parseOrderBy;
final readonly class Ordering
{
private const string DESC_DIR = 'DESC';
private const string ASC_DIR = 'ASC';
public const array VALID_ORDER_DIRS = [self::ASC_DIR, self::DESC_DIR];
private const string DEFAULT_DIR = self::ASC_DIR;
public function __construct(public string|null $field = null, public string $direction = self::DEFAULT_DIR)
@@ -28,6 +31,11 @@ final readonly class Ordering
return new self($field, $dir ?? self::DEFAULT_DIR);
}
public static function fromOptionalString(string|null $value): self
{
return $value === null ? self::none() : self::fromTuple(parseOrderBy($value));
}
public static function fromFieldAsc(string $field): self
{
return new self($field, self::ASC_DIR);
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ObjectMapper;
use Attribute;
use CuyZ\Valinor\Mapper\AsConverter;
use Shlinkio\Shlink\Common\ObjectMapper\MappingError;
use Shlinkio\Shlink\Core\Model\Ordering;
use function implode;
use function Shlinkio\Shlink\Common\parseOrderBy;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function sprintf;
#[AsConverter]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final readonly class OrderingConverter
{
/**
* @param string[] $validFields
*/
public function __construct(private array|null $validFields = null)
{
}
public function map(string|null $value): Ordering
{
if ($value === null || $value === '') {
return Ordering::none();
}
[$field, $dir] = parseOrderBy($value);
if ($this->validFields !== null && ! contains($field, $this->validFields)) {
throw MappingError::withBody(
sprintf('Resolved order field is not one of ["%s"]', implode('", "', $this->validFields)),
);
}
if ($dir !== null && ! contains($dir, Ordering::VALID_ORDER_DIRS)) {
throw MappingError::withBody('Resolved order direction has to be one of ["ASC", "DESC"]');
}
return Ordering::fromTuple([$field, $dir]);
}
}
@@ -4,11 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use DateTimeInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ObjectMapper\OrderingConverter;
use function array_unique;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Common\normalizeOptionalDate;
@@ -19,68 +20,43 @@ final readonly class ShortUrlsParams
{
public const int DEFAULT_ITEMS_PER_PAGE = 10;
private function __construct(
public int $page,
public int $itemsPerPage,
public string|null $searchTerm,
public array $tags,
public Ordering $orderBy,
public DateRange|null $dateRange,
public bool $excludeMaxVisitsReached,
public bool $excludePastValidUntil,
public DateRange|null $dateRange;
/** @var string[] */
public array $tags;
/** @var string[] */
public array $excludeTags;
/**
* @param string[] $tags
* @param string[] $excludeTags
*/
public function __construct(
public int $page = 1,
public int $itemsPerPage = self::DEFAULT_ITEMS_PER_PAGE,
public string|null $searchTerm = null,
array $tags = [],
#[OrderingConverter]
public Ordering $orderBy = new Ordering(),
DateTimeInterface|string|null $startDate = null,
DateTimeInterface|string|null $endDate = null,
public bool $excludeMaxVisitsReached = false,
public bool $excludePastValidUntil = false,
public TagsMode $tagsMode = TagsMode::ANY,
public string|null $domain = null,
public array $excludeTags = [],
array $excludeTags = [],
public TagsMode $excludeTagsMode = TagsMode::ANY,
public string|null $apiKeyName = null,
) {
$this->dateRange = buildDateRange(
normalizeOptionalDate($startDate),
normalizeOptionalDate($endDate),
);
$this->tags = array_unique($tags);
$this->excludeTags = array_unique($excludeTags);
}
public static function empty(): self
{
return self::fromRawData([]);
}
/**
* @throws ValidationException
*/
public static function fromRawData(array $query): self
{
$inputFilter = new ShortUrlsParamsInputFilter($query);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
return new self(
page: (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1),
itemsPerPage: (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
),
searchTerm: $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM),
tags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS),
orderBy: Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)),
dateRange: buildDateRange(
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
),
excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED),
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
excludeTags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS),
excludeTagsMode: self::resolveTagsMode(
$inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE),
),
apiKeyName: $inputFilter->getValue(ShortUrlsParamsInputFilter::API_KEY_NAME),
);
}
private static function resolveTagsMode(string|null $rawTagsMode): TagsMode
{
if ($rawTagsMode === null) {
return TagsMode::ANY;
}
return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY;
return new self();
}
}
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use function Shlinkio\Shlink\Core\enumValues;
/** @extends InputFilter<mixed> */
class ShortUrlsParamsInputFilter extends InputFilter
{
public const string PAGE = 'page';
public const string SEARCH_TERM = 'searchTerm';
public const string TAGS = 'tags';
public const string TAGS_MODE = 'tagsMode';
public const string EXCLUDE_TAGS = 'excludeTags';
public const string EXCLUDE_TAGS_MODE = 'excludeTagsMode';
public const string START_DATE = 'startDate';
public const string END_DATE = 'endDate';
public const string ITEMS_PER_PAGE = 'itemsPerPage';
public const string ORDER_BY = 'orderBy';
public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
public const string DOMAIN = 'domain';
public const string API_KEY_NAME = 'apiKeyName';
public function __construct(array $data)
{
$this->initialize();
$this->setData($data);
}
private function initialize(): void
{
$this->add(InputFactory::date(self::START_DATE));
$this->add(InputFactory::date(self::END_DATE));
$this->add(InputFactory::basic(self::SEARCH_TERM));
$this->add(InputFactory::numeric(self::PAGE));
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
$this->add(InputFactory::tags(self::TAGS));
$this->add($this->createTagsModeInput(self::TAGS_MODE));
$this->add(InputFactory::tags(self::EXCLUDE_TAGS));
$this->add($this->createTagsModeInput(self::EXCLUDE_TAGS_MODE));
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
$this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED));
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
$this->add(InputFactory::basic(self::DOMAIN));
$this->add(InputFactory::basic(self::API_KEY_NAME));
}
private function createTagsModeInput(string $name): Input
{
$tagsMode = InputFactory::basic($name);
$tagsMode->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(TagsMode::class),
'strict' => InArray::COMPARE_STRICT,
]));
return $tagsMode;
}
}
+1 -3
View File
@@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Core\Tag\Model;
use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams;
use Shlinkio\Shlink\Core\Model\Ordering;
use function Shlinkio\Shlink\Common\parseOrderBy;
final class TagsParams extends AbstractInfinitePaginableListParams
{
private function __construct(
@@ -24,7 +22,7 @@ final class TagsParams extends AbstractInfinitePaginableListParams
{
return new self(
$query['searchTerm'] ?? null,
isset($query['orderBy']) ? Ordering::fromTuple(parseOrderBy($query['orderBy'])) : Ordering::none(),
Ordering::fromOptionalString($query['orderBy'] ?? null),
isset($query['page']) ? (int) $query['page'] : null,
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
);
@@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
@@ -34,13 +35,13 @@ class ShortUrlRepositoryAdapterTest extends TestCase
string|null $endDate = null,
string|null $orderBy = null,
): void {
$params = ShortUrlsParams::fromRawData([
'searchTerm' => $searchTerm,
'tags' => $tags,
'startDate' => $startDate,
'endDate' => $endDate,
'orderBy' => $orderBy,
]);
$params = new ShortUrlsParams(
searchTerm: $searchTerm,
tags: $tags,
orderBy: Ordering::fromOptionalString($orderBy),
startDate: $startDate,
endDate: $endDate,
);
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, null, '');
$orderBy = $params->orderBy;
$dateRange = $params->dateRange;
@@ -59,12 +60,12 @@ class ShortUrlRepositoryAdapterTest extends TestCase
string|null $startDate = null,
string|null $endDate = null,
): void {
$params = ShortUrlsParams::fromRawData([
'searchTerm' => $searchTerm,
'tags' => $tags,
'startDate' => $startDate,
'endDate' => $endDate,
]);
$params = new ShortUrlsParams(
searchTerm: $searchTerm,
tags: $tags,
startDate: $startDate,
endDate: $endDate,
);
$apiKey = ApiKey::create();
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, '');
$dateRange = $params->dateRange;