Merge branch 'master' of github.com:shlinkio/shlink

This commit is contained in:
Alejandro Celaya 2018-12-09 14:22:40 +01:00
commit baeba54b06
15 changed files with 259 additions and 55 deletions

View File

@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Changed #### Changed
* [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image. * [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image.
* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2.
#### Deprecated #### Deprecated

View File

@ -30,10 +30,10 @@
"mikehaertl/phpwkhtmltopdf": "^2.2", "mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21", "monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"symfony/console": "^4.1", "symfony/console": "^4.2",
"symfony/filesystem": "^4.1", "symfony/filesystem": "^4.2",
"symfony/lock": "^4.1", "symfony/lock": "^4.2",
"symfony/process": "^4.1", "symfony/process": "^4.2",
"theorchard/monolog-cascade": "^0.4", "theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0", "zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0", "zendframework/zend-config-aggregator": "^1.0",
@ -57,8 +57,8 @@
"phpunit/phpcov": "^5.0", "phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.3", "phpunit/phpunit": "^7.3",
"shlinkio/php-coding-standard": "~1.0.0", "shlinkio/php-coding-standard": "~1.0.0",
"symfony/dotenv": "^4.0", "symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.0", "symfony/var-dumper": "^4.2",
"zendframework/zend-component-installer": "^2.1", "zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0" "zendframework/zend-expressive-tooling": "^1.0"
}, },

View File

@ -26,7 +26,7 @@ $config['entity_manager']['connection'] = [
$sm->setService('config', $config); $sm->setService('config', $config);
// Create database // Create database
$process = new Process('vendor/bin/doctrine orm:schema-tool:create --no-interaction -q --test', __DIR__); $process = new Process(['vendor/bin/doctrine', 'orm:schema-tool:create', '--no-interaction', '-q', '--test'], __DIR__);
$process->inheritEnvironmentVariables() $process->inheritEnvironmentVariables()
->mustRun(); ->mustRun();

View File

@ -3,13 +3,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api; namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter; use function array_filter;
use function array_map; use function array_map;
use function sprintf; use function sprintf;
@ -46,7 +46,6 @@ class ListKeysCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void protected function execute(InputInterface $input, OutputInterface $output): void
{ {
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly'); $enabledOnly = $input->getOption('enabledOnly');
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) { $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
@ -62,7 +61,7 @@ class ListKeysCommand extends Command
return $rowData; return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly)); }, $this->apiKeyService->listKeys($enabledOnly));
$io->table(array_filter([ ShlinkTable::fromOutput($output)->render(array_filter([
'Key', 'Key',
! $enabledOnly ? 'Is enabled' : null, ! $enabledOnly ? 'Is enabled' : null,
'Expiration date', 'Expiration date',

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
@ -69,7 +70,6 @@ class GetVisitsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void protected function execute(InputInterface $input, OutputInterface $output): void
{ {
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode'); $shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate'); $startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate'); $endDate = $this->getDateOption($input, 'endDate');
@ -82,7 +82,7 @@ class GetVisitsCommand extends Command
$rowData['country'] = $visit->getVisitLocation()->getCountryName(); $rowData['country'] = $visit->getVisitLocation()->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
}, $visits); }, $visits);
$io->table(['Referer', 'Date', 'User agent', 'Country'], $rows); ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
} }
private function getDateOption(InputInterface $input, $key) private function getDateOption(InputInterface $input, $key)

View File

@ -3,8 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@ -12,6 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Paginator\Paginator;
use function array_values; use function array_values;
use function count; use function count;
use function explode; use function explode;
@ -78,39 +81,56 @@ class ListShortUrlsCommand extends Command
$searchTerm = $input->getOption('searchTerm'); $searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags'); $tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : []; $tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = $input->getOption('showTags'); $showTags = (bool) $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig); $transformer = new ShortUrlDataTransformer($this->domainConfig);
do { do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); $result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer);
$page++; $page++;
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count']; $continue = $this->isLastPage($result)
if ($showTags) { ? false
$headers[] = 'Tags'; : $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
}
$io->table($headers, $rows);
if ($this->isLastPage($result)) {
$continue = false;
$io->success('Short URLs properly listed');
} else {
$continue = $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
}
} while ($continue); } while ($continue);
$io->newLine();
$io->success('Short URLs properly listed');
}
private function renderPage(
InputInterface $input,
OutputInterface $output,
int $page,
?string $searchTerm,
array $tags,
bool $showTags,
DataTransformerInterface $transformer
): Paginator {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(
$result,
'Page %s of %s'
));
return $result;
} }
private function processOrderBy(InputInterface $input) private function processOrderBy(InputInterface $input)

View File

@ -3,12 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag; namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map; use function Functional\map;
class ListTagsCommand extends Command class ListTagsCommand extends Command
@ -33,8 +33,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void protected function execute(InputInterface $input, OutputInterface $output): void
{ {
$io = new SymfonyStyle($input, $output); ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
$io->table(['Name'], $this->getTagsRows());
} }
private function getTagsRows(): array private function getTagsRows(): array

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Console;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\OutputInterface;
final class ShlinkTable
{
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
/** @var Table|null */
private $baseTable;
public function __construct(Table $baseTable)
{
$this->baseTable = $baseTable;
}
public static function fromOutput(OutputInterface $output): self
{
return new self(new Table($output));
}
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
{
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
$table = clone $this->baseTable;
$table->setStyle($style)
->setHeaders($headers)
->setRows($rows)
->setFooterTitle($footerTitle)
->setHeaderTitle($headerTitle)
->render();
}
}

View File

@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Zend\Paginator\Paginator; use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils; use Zend\Stdlib\ArrayUtils;
use function array_map; use function array_map;
use function sprintf;
trait PaginatorUtilsTrait trait PaginatorUtilsTrait
{ {
@ -39,4 +40,9 @@ trait PaginatorUtilsTrait
{ {
return $paginator->getCurrentPageNumber() >= $paginator->count(); return $paginator->getCurrentPageNumber() >= $paginator->count();
} }
private function formatCurrentPageMessage(Paginator $paginator, string $pattern): string
{
return sprintf($pattern, $paginator->getCurrentPageNumber(), $paginator->count());
}
} }

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Console;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableStyle;
use Symfony\Component\Console\Output\OutputInterface;
class ShlinkTableTest extends TestCase
{
/** @var ShlinkTable */
private $shlinkTable;
/** @var ObjectProphecy */
private $baseTable;
public function setUp()
{
$this->baseTable = $this->prophesize(Table::class);
$this->shlinkTable = new ShlinkTable($this->baseTable->reveal());
}
/**
* @test
*/
public function renderMakesTableToBeRenderedWithProvidedInfo()
{
$headers = [];
$rows = [[]];
$headerTitle = 'Header';
$footerTitle = 'Footer';
$setStyle = $this->baseTable->setStyle(Argument::type(TableStyle::class))->willReturn(
$this->baseTable->reveal()
);
$setHeaders = $this->baseTable->setHeaders($headers)->willReturn($this->baseTable->reveal());
$setRows = $this->baseTable->setRows($rows)->willReturn($this->baseTable->reveal());
$setFooterTitle = $this->baseTable->setFooterTitle($footerTitle)->willReturn($this->baseTable->reveal());
$setHeaderTitle = $this->baseTable->setHeaderTitle($headerTitle)->willReturn($this->baseTable->reveal());
$render = $this->baseTable->render()->willReturn($this->baseTable->reveal());
$this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle);
$setStyle->shouldHaveBeenCalledOnce();
$setHeaders->shouldHaveBeenCalledOnce();
$setRows->shouldHaveBeenCalledOnce();
$setFooterTitle->shouldHaveBeenCalledOnce();
$setHeaderTitle->shouldHaveBeenCalledOnce();
$render->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function newTableIsCreatedForFactoryMethod()
{
$instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal());
$ref = new ReflectionObject($instance);
$baseTable = $ref->getProperty('baseTable');
$baseTable->setAccessible(true);
$this->assertInstanceOf(Table::class, $baseTable->getValue($instance));
}
}

View File

@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
/** /**
* Class Visit * Class Visit
@ -88,9 +90,9 @@ class Visit extends AbstractEntity implements JsonSerializable
return ! empty($this->remoteAddr); return ! empty($this->remoteAddr);
} }
public function getVisitLocation(): VisitLocation public function getVisitLocation(): VisitLocationInterface
{ {
return $this->visitLocation; return $this->visitLocation ?? new UnknownVisitLocation();
} }
public function locate(VisitLocation $visitLocation): self public function locate(VisitLocation $visitLocation): self

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity; namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use function array_key_exists; use function array_key_exists;
/** /**
@ -16,7 +16,7 @@ use function array_key_exists;
* @ORM\Entity() * @ORM\Entity()
* @ORM\Table(name="visit_locations") * @ORM\Table(name="visit_locations")
*/ */
class VisitLocation extends AbstractEntity implements JsonSerializable class VisitLocation extends AbstractEntity implements VisitLocationInterface
{ {
/** /**
* @var string * @var string

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
final class UnknownVisitLocation implements VisitLocationInterface
{
public function getCountryName(): string
{
return 'Unknown';
}
public function getLatitude(): string
{
return '0.0';
}
public function getLongitude(): string
{
return '0.0';
}
public function getCityName(): string
{
return 'Unknown';
}
/**
* Specify data which should be serialized to JSON
* @link https://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return [
'countryCode' => 'Unknown',
'countryName' => 'Unknown',
'regionName' => 'Unknown',
'cityName' => 'Unknown',
'latitude' => '0.0',
'longitude' => '0.0',
'timezone' => 'Unknown',
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
use JsonSerializable;
interface VisitLocationInterface extends JsonSerializable
{
public function getCountryName(): string;
public function getLatitude(): string;
public function getLongitude(): string;
public function getCityName(): string;
}

View File

@ -20,7 +20,8 @@ use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface; use Zend\Config\Writer\WriterInterface;
use function sprintf; use function array_unshift;
use function implode;
class InstallCommand extends Command class InstallCommand extends Command
{ {
@ -133,7 +134,7 @@ class InstallCommand extends Command
if (! $this->isUpdate) { if (! $this->isUpdate) {
$this->io->write('Initializing database...'); $this->io->write('Initializing database...');
if (! $this->execPhp( if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create', ['vendor/doctrine/orm/bin/doctrine.php', 'orm:schema-tool:create'],
'Error generating database.', 'Error generating database.',
$output $output
)) { )) {
@ -144,7 +145,7 @@ class InstallCommand extends Command
// Run database migrations // Run database migrations
$this->io->write('Updating database...'); $this->io->write('Updating database...');
if (! $this->execPhp( if (! $this->execPhp(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate', ['vendor/doctrine/migrations/bin/doctrine-migrations.php', 'migrations:migrate'],
'Error updating database.', 'Error updating database.',
$output $output
)) { )) {
@ -154,16 +155,16 @@ class InstallCommand extends Command
// Generate proxies // Generate proxies
$this->io->write('Generating proxies...'); $this->io->write('Generating proxies...');
if (! $this->execPhp( if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies', ['vendor/doctrine/orm/bin/doctrine.php', 'orm:generate-proxies'],
'Error generating proxies.', 'Error generating proxies.',
$output $output
)) { )) {
return; return;
} }
// Download GeoLite2 db filte // Download GeoLite2 db file
$this->io->write('Downloading GeoLite2 db...'); $this->io->write('Downloading GeoLite2 db...');
if (! $this->execPhp('bin/cli visit:update-db', 'Error downloading GeoLite2 db.', $output)) { if (! $this->execPhp(['bin/cli', 'visit:update-db'], 'Error downloading GeoLite2 db.', $output)) {
return; return;
} }
@ -215,7 +216,7 @@ class InstallCommand extends Command
return $config; return $config;
} }
private function execPhp(string $command, string $errorMessage, OutputInterface $output): bool private function execPhp(array $command, string $errorMessage, OutputInterface $output): bool
{ {
if ($this->processHelper === null) { if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process'); $this->processHelper = $this->getHelper('process');
@ -225,12 +226,13 @@ class InstallCommand extends Command
$this->phpBinary = $this->phpFinder->find(false) ?: 'php'; $this->phpBinary = $this->phpFinder->find(false) ?: 'php';
} }
array_unshift($command, $this->phpBinary);
$this->io->write( $this->io->write(
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ', ' <options=bold>[Running "' . implode(' ', $command) . '"]</> ',
false, false,
OutputInterface::VERBOSITY_VERBOSE OutputInterface::VERBOSITY_VERBOSE
); );
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command)); $process = $this->processHelper->run($output, $command);
if ($process->isSuccessful()) { if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>'); $this->io->writeln(' <info>Success!</info>');
return true; return true;