Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this

Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.

* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.

Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.

* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.

Expand Down
6 changes: 3 additions & 3 deletions module/CLI/config/dependencies.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],

Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Expand All @@ -119,11 +119,11 @@
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class],

Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class],

Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,
Expand Down
19 changes: 3 additions & 16 deletions module/CLI/src/Command/Domain/GetDomainVisitsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
Expand All @@ -22,10 +20,8 @@ class GetDomainVisitsCommand extends Command
{
public const string NAME = 'domain:visits';

public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}

Expand All @@ -36,17 +32,8 @@ public function __invoke(
#[MapInput] VisitsListInput $input,
): int {
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);

return self::SUCCESS;
}

/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}
19 changes: 3 additions & 16 deletions module/CLI/src/Command/Tag/GetTagVisitsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
Expand All @@ -24,10 +22,8 @@ class GetTagVisitsCommand extends Command
{
public const string NAME = 'tag:visits';

public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}

Expand All @@ -47,17 +43,8 @@ public function __invoke(
domain: $domain,
));

VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);

return self::SUCCESS;
}

/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}
19 changes: 3 additions & 16 deletions module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\AsCommand;
Expand All @@ -21,10 +19,8 @@ class GetNonOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:non-orphan';

public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}

Expand All @@ -42,17 +38,8 @@ public function __invoke(
dateRange: $input->dateRange(),
domain: $domain,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);

return self::SUCCESS;
}

/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}
11 changes: 1 addition & 10 deletions module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
Expand Down Expand Up @@ -42,16 +41,8 @@ public function __invoke(
domain: $domain,
type: $type,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);

return self::SUCCESS;
}

/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type->value];
}
}
75 changes: 33 additions & 42 deletions module/CLI/src/Command/Visit/VisitsCommandUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,12 @@
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Symfony\Component\Console\Output\OutputInterface;

use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;

class VisitsCommandUtils
{
/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
public static function renderOutput(
OutputInterface $output,
Expand All @@ -36,25 +32,21 @@ public static function renderOutput(
}

match ($inputData->format) {
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields),
default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields),
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
default => self::renderHumanFriendlyOutput($output, $paginator),
};
}

/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function renderCSVOutput(
OutputInterface $output,
Paginator $paginator,
callable|null $mapExtraFields,
): void {
private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);

[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
$csv = Writer::fromString();
if ($page === 1) {
$csv->insertOne($headers);
Expand All @@ -69,19 +61,15 @@ private static function renderCSVOutput(

/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function renderHumanFriendlyOutput(
OutputInterface $output,
Paginator $paginator,
callable|null $mapExtraFields,
): void {
private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);
$page++;

[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render(
$headers,
$rows,
Expand All @@ -92,35 +80,38 @@ private static function renderHumanFriendlyOutput(

/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array
private static function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = null;
$mapExtraFields ??= static fn (Visit $_) => [];

$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) {
$extraFields = $mapExtraFields($visit);
$extraKeys ??= array_keys($extraFields);
$headers = [
'Date',
'Potential bot',
'User agent',
'Referer',
'Country',
'Region',
'City',
'Visited URL',
'Redirect URL',
'Type',
];
$rows = array_map(function (Visit $visit) {
$visitLocation = $visit->visitLocation;

$rowData = [
'referer' => $visit->referer,
return [
'date' => $visit->date->toAtomString(),
'potentialBot' => $visit->potentialBot ? 'Potential bot' : '',
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
'referer' => $visit->referer,
'country' => $visitLocation->countryName ?? 'Unknown',
'region' => $visitLocation->regionName ?? 'Unknown',
'city' => $visitLocation->cityName ?? 'Unknown',
'visitedUrl' => $visit->visitedUrl ?? 'Unknown',
'redirectUrl' => $visit->redirectUrl ?? 'Unknown',
'type' => $visit->type->value,
];

// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}, [...$paginator->getCurrentPageResults()]);
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []);

return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
return [$rows, $headers];
}
}
25 changes: 10 additions & 15 deletions module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
Expand All @@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;

protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);

$this->commandTester = CliTestUtils::testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper));
}

#[Test]
Expand All @@ -48,22 +43,22 @@ public function outputIsProperlyGenerated(): void
$domain,
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'the_short_url',
);

$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;

self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+

OUTPUT,
// phpcs:enable
$output,
);
}
Expand Down
Loading