mirror of
https://github.com/shlinkio/shlink.git
synced 2026-11-02 09:49:25 -06:00
Use valinor to validate and map short URL list params
This commit is contained in:
1 parent
14ef24a079
commit
35257c5e3e
13 files changed
+181
-281
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user