Allow type filter property for orphan visits list

This commit is contained in:
Alejandro Celaya 2024-02-10 17:51:42 +01:00
parent 46acf4de1c
commit 48a8290e92
12 changed files with 144 additions and 16 deletions

View file

@ -199,7 +199,7 @@ services:
shlink_swagger_ui: shlink_swagger_ui:
container_name: shlink_swagger_ui container_name: shlink_swagger_ui
image: swaggerapi/swagger-ui:v5.10.3 image: swaggerapi/swagger-ui:v5.11.3
ports: ports:
- "8005:8080" - "8005:8080"
volumes: volumes:

View file

@ -55,6 +55,16 @@
"type": "string", "type": "string",
"enum": ["true"] "enum": ["true"]
} }
},
{
"name": "type",
"in": "query",
"description": "The type of visits to return. All visits are returned when not provided.",
"required": false,
"schema": {
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
} }
], ],
"security": [ "security": [
@ -137,6 +147,54 @@
} }
} }
}, },
"400": {
"description": "Provided query arguments are invalid.",
"content": {
"application/problem+json": {
"schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": ["type"]
}
}
}
}
]
},
"examples": {
"API v3 and newer": {
"value": {
"title": "Invalid data",
"type": "https://shlink.io/api/error/invalid-data",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["type"]
}
},
"Previous to API v3": {
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["type"]
}
}
}
}
}
},
"default": { "default": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
class GetOrphanVisitsCommand extends AbstractVisitsListCommand class GetOrphanVisitsCommand extends AbstractVisitsListCommand
@ -23,7 +23,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{ {
return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange)); return $this->visitsHelper->orphanVisits(new OrphanVisitsParams($dateRange));
} }
/** /**

View file

@ -0,0 +1,53 @@
<?php
namespace Shlinkio\Shlink\Core\Visit\Model;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use ValueError;
use function implode;
use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
final class OrphanVisitsParams extends VisitsParams
{
public function __construct(
?DateRange $dateRange = null,
?int $page = null,
?int $itemsPerPage = null,
bool $excludeBots = false,
public readonly ?OrphanVisitType $type = null,
) {
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots);
}
public static function fromRawData(array $query): self
{
$visitsParams = parent::fromRawData($query);
$type = $query['type'] ?? null;
return new self(
dateRange: $visitsParams->dateRange,
page: $visitsParams->page,
itemsPerPage: $visitsParams->itemsPerPage,
excludeBots: $visitsParams->excludeBots,
type: $type !== null ? self::parseType($type) : null,
);
}
private static function parseType(string $type): OrphanVisitType
{
try {
return OrphanVisitType::from($type);
} catch (ValueError) {
throw ValidationException::fromArray([
'type' => sprintf(
'%s is not a valid orphan visit type. Expected one of ["%s"]',
$type,
implode('", "', enumValues(OrphanVisitType::class)),
),
]);
}
}
}

View file

@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams;
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
final class VisitsParams extends AbstractInfinitePaginableListParams class VisitsParams extends AbstractInfinitePaginableListParams
{ {
public readonly DateRange $dateRange; public readonly DateRange $dateRange;

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter; namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
@ -15,7 +15,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
{ {
public function __construct( public function __construct(
private readonly VisitRepositoryInterface $repo, private readonly VisitRepositoryInterface $repo,
private readonly VisitsParams $params, private readonly OrphanVisitsParams $params,
private readonly ?ApiKey $apiKey, private readonly ?ApiKey $apiKey,
) { ) {
} }
@ -26,6 +26,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
dateRange: $this->params->dateRange, dateRange: $this->params->dateRange,
excludeBots: $this->params->excludeBots, excludeBots: $this->params->excludeBots,
apiKey: $this->apiKey, apiKey: $this->apiKey,
type: $this->params->type,
)); ));
} }
@ -35,6 +36,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
dateRange: $this->params->dateRange, dateRange: $this->params->dateRange,
excludeBots: $this->params->excludeBots, excludeBots: $this->params->excludeBots,
apiKey: $this->apiKey, apiKey: $this->apiKey,
type: $this->params->type,
limit: $length, limit: $length,
offset: $offset, offset: $offset,
)); ));

View file

@ -18,6 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter;
@ -117,7 +118,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
/** /**
* @return Visit[]|Paginator * @return Visit[]|Paginator
*/ */
public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator
{ {
/** @var VisitRepositoryInterface $repo */ /** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class); $repo = $this->em->getRepository(Visit::class);

View file

@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -43,7 +44,7 @@ interface VisitsStatsHelperInterface
/** /**
* @return Visit[]|Paginator * @return Visit[]|Paginator
*/ */
public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/** /**
* @return Visit[]|Paginator * @return Visit[]|Paginator

View file

@ -9,8 +9,8 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
@ -21,13 +21,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase
{ {
private OrphanVisitsPaginatorAdapter $adapter; private OrphanVisitsPaginatorAdapter $adapter;
private MockObject & VisitRepositoryInterface $repo; private MockObject & VisitRepositoryInterface $repo;
private VisitsParams $params; private OrphanVisitsParams $params;
private ApiKey $apiKey; private ApiKey $apiKey;
protected function setUp(): void protected function setUp(): void
{ {
$this->repo = $this->createMock(VisitRepositoryInterface::class); $this->repo = $this->createMock(VisitRepositoryInterface::class);
$this->params = VisitsParams::fromRawData([]); $this->params = OrphanVisitsParams::fromRawData([]);
$this->apiKey = ApiKey::create(); $this->apiKey = ApiKey::create();
$this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey);

View file

@ -23,6 +23,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
@ -260,7 +261,7 @@ class VisitsStatsHelperTest extends TestCase
)->willReturn($list); )->willReturn($list);
$this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo);
$paginator = $this->helper->orphanVisits(new VisitsParams()); $paginator = $this->helper->orphanVisits(new OrphanVisitsParams());
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
} }

View file

@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@ -29,7 +29,7 @@ class OrphanVisitsAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface public function handle(ServerRequestInterface $request): ResponseInterface
{ {
$params = VisitsParams::fromRawData($request->getQueryParams()); $params = OrphanVisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsHelper->orphanVisits($params, $apiKey); $visits = $this->visitsHelper->orphanVisits($params, $apiKey);

View file

@ -12,9 +12,10 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction; use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -41,7 +42,7 @@ class OrphanVisitsActionTest extends TestCase
$visitor = Visitor::emptyInstance(); $visitor = Visitor::emptyInstance();
$visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)];
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with( $this->visitsHelper->expects($this->once())->method('orphanVisits')->with(
$this->isInstanceOf(VisitsParams::class), $this->isInstanceOf(OrphanVisitsParams::class),
)->willReturn(new Paginator(new ArrayAdapter($visits))); )->willReturn(new Paginator(new ArrayAdapter($visits)));
$visitsAmount = count($visits); $visitsAmount = count($visits);
$this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with( $this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with(
@ -57,4 +58,15 @@ class OrphanVisitsActionTest extends TestCase
self::assertCount($visitsAmount, $payload['visits']['data']); self::assertCount($visitsAmount, $payload['visits']['data']);
self::assertEquals(200, $response->getStatusCode()); self::assertEquals(200, $response->getStatusCode());
} }
#[Test]
public function exceptionIsThrownIfInvalidDataIsProvided(): void
{
$this->expectException(ValidationException::class);
$this->action->handle(
ServerRequestFactory::fromGlobals()
->withAttribute(ApiKey::class, ApiKey::create())
->withQueryParams(['type' => 'invalidType']),
);
}
} }