Compare commits

..

22 Commits

Author SHA1 Message Date
Alejandro Celaya
30297ac5ac Merge pull request #176 from acelaya/feature/1.10.2
feature/1.10.2
2018-08-04 16:51:01 +02:00
Alejandro Celaya
416c56dee2 Added new spanish translations 2018-08-04 16:37:54 +02:00
Alejandro Celaya
6b968a6843 Updated changelog including v1.10.2 2018-08-04 16:28:12 +02:00
Alejandro Celaya
080965e166 Improved ShortUrlRepositoryTest covering listing case with filter by tag and search term at the same time 2018-08-04 16:21:01 +02:00
Alejandro Celaya
c7239aaca2 Fixed duplicated join with same table performed while filtering short codes by search term and tags 2018-08-04 16:15:09 +02:00
Alejandro Celaya
110e8cb78d Added test to cover new IP resolution API limits 2018-08-04 15:50:02 +02:00
Alejandro Celaya
ed859767a8 Updated IpLocation resolver to be able to provide limits in order to apply sleeps 2018-08-02 23:02:48 +02:00
Alejandro Celaya
d54b823c88 Merge branch 'develop' 2018-08-02 17:56:38 +02:00
Alejandro Celaya
0ae44d3331 Merge pull request #168 from acelaya/feature/1.10.1
Feature/1.10.1
2018-08-02 17:55:25 +02:00
Alejandro Celaya
8063e643a3 Updated changelog with version 1.10.1 2018-08-01 20:58:48 +02:00
Alejandro Celaya
3883ed15c4 Fixed short codes DB length too short 2018-08-01 20:40:24 +02:00
Alejandro Celaya
a79c1f580e Fixed visits count multiplied by the number of tags when ordering and filtering by text 2018-08-01 20:31:54 +02:00
Alejandro Celaya
f4b569c245 Improved code 2018-08-01 20:28:05 +02:00
Alejandro Celaya
899771cc2e Fixed geolocation by switching to different API 2018-07-31 20:24:13 +02:00
Alejandro Celaya
863803b614 Fixed tests failing with new typehints 2018-07-31 19:59:41 +02:00
Alejandro Celaya
5be5e0bc60 Fixed coding styles 2018-07-31 19:53:59 +02:00
Alejandro Celaya
0b8e305533 Improved error management in process visits command 2018-07-31 19:42:33 +02:00
Alejandro Celaya
39d79366a3 Documented date range params for visits endpoint 2018-07-30 20:28:41 +02:00
Alejandro Celaya
d5b78f2a7e Fixed date fields not properly parsed depending if originally they were datetimes or strings 2018-07-28 18:57:24 +02:00
Alejandro Celaya
b2a63f734a Simplified how built shlink version is found out 2018-07-26 20:35:02 +02:00
Alejandro Celaya
82f41de87b Added build step which sets shlink's version 2018-07-26 18:44:04 +02:00
Alejandro Celaya
af4c66d40a Added version placeholder in place of hardcoded version in config 2018-07-26 18:42:53 +02:00
31 changed files with 479 additions and 161 deletions

View File

@@ -1,5 +1,63 @@
# CHANGELOG
## 1.10.2 - 2018-08-04
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time.
* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations.
In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned.
In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses.
## 1.10.1 - 2018-08-02
#### Added
* *Nothing*
#### Changed
* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters.
* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint.
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
## 1.10.0 - 2018-07-09
#### Added

View File

@@ -46,6 +46,9 @@ rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Update shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"

View File

@@ -7,7 +7,7 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.7.0',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
],

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180801183328 extends AbstractMigration
{
private const NEW_SIZE = 255;
private const OLD_SIZE = 10;
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$this->setSize($schema, self::NEW_SIZE);
}
/**
* @param Schema $schema
* @throws SchemaException
*/
public function down(Schema $schema): void
{
$this->setSize($schema, self::OLD_SIZE);
}
/**
* @param Schema $schema
* @param int $size
* @throws SchemaException
*/
private function setSize(Schema $schema, int $size): void
{
$schema->getTable('short_urls')->getColumn('short_code')->setLength($size);
}
}

View File

@@ -25,7 +25,7 @@
}
},
{
"name": "tags",
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"required": false,

View File

@@ -15,6 +15,24 @@
"schema": {
"type": "string"
}
},
{
"name": "startDate",
"in": "query",
"description": "The date (in ISO-8601 format) from which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "endDate",
"in": "query",
"description": "The date (in ISO-8601 format) until which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -51,7 +51,7 @@ return [
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolver::class,
IpApiLocationResolver::class,
'translator',
],
Command\Config\GenerateCharsetCommand::class => ['translator'],

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-01-21 09:36+0100\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n"
"POT-Creation-Date: 2018-08-04 16:35+0200\n"
"PO-Revision-Date: 2018-08-04 16:37+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.4\n"
"X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -317,6 +317,13 @@ msgstr "Ignorada IP de localhost"
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "An error occurred while locating IP"
msgstr "Se produjo un error al localizar la IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"

View File

@@ -15,8 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process';
private const LOCALHOST = '127.0.0.1';
public const NAME = 'visit:process';
/**
* @var VisitServiceInterface
@@ -55,16 +55,18 @@ class ProcessVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
$io->write(\sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) {
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
$count++;
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
@@ -73,12 +75,27 @@ class ProcessVisitsCommand extends Command
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$io->writeln(sprintf(
$io->writeln(\sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
continue;
$io->writeln(
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(\sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
\sleep($seconds);
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -37,7 +38,8 @@ class GenerateKeyCommandTest extends TestCase
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
@@ -46,9 +48,10 @@ class GenerateKeyCommandTest extends TestCase
/**
* @test
*/
public function expirationDateIsDefinedIfWhenProvided()
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
@@ -52,7 +53,7 @@ class CreateTagCommandTest extends TestCase
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $createTags */
$createTags = $this->tagService->createTags($tagNames)->willReturn([]);
$createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
$this->commandTester->execute([
'--name' => $tagNames,

View File

@@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application;
@@ -32,7 +32,9 @@ class ProcessVisitsCommandTest extends TestCase
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpLocationResolver::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->ipResolver->getApiLimit()->willReturn(10000000000);
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
@@ -95,4 +97,41 @@ class ProcessVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
}
/**
* @test
*/
public function sleepsEveryTimeTheApiLimitIsReached()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('4.3.2.1'),
];
$apiLimit = 3;
$this->visitService->getUnlocatedVisits()->willReturn($visits);
$this->visitService->saveVisit(Argument::any())->will(function () {
});
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$getApiLimit->shouldHaveBeenCalledTimes(\count($visits));
$getApiInterval->shouldHaveBeenCalledTimes(\round(\count($visits) / $apiLimit));
$resolveIpLocation->shouldHaveBeenCalledTimes(\count($visits));
}
}

View File

@@ -32,7 +32,7 @@ return [
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpLocationResolver::class => ConfigAbstractFactory::class,
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
@@ -51,7 +51,7 @@ return [
ConfigAbstractFactory::class => [
TranslatorExtension::class => ['translator'],
LocaleMiddleware::class => ['translator'],
Service\IpLocationResolver::class => ['httpClient'],
Service\IpApiLocationResolver::class => ['httpClient'],
Service\PreviewGenerator::class => [
ImageBuilder::class,
Filesystem::class,

View File

@@ -23,8 +23,7 @@ class EntityManagerFactory implements FactoryInterface
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
@@ -32,14 +31,14 @@ class EntityManagerFactory implements FactoryInterface
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
$emConfig = $globalConfig['entity_manager'] ?? [];
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],
$isDevMode,
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
$ormConfig['proxies_dir'] ?? null,
$cache,
false
));

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpApiLocationResolver implements IpLocationResolverInterface
{
private const SERVICE_PATTERN = 'http://ip-api.com/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(\sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(\json_decode((string) $response->getBody(), true));
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
private function mapFields(array $entry): array
{
return [
'country_code' => $entry['countryCode'] ?? '',
'country_name' => $entry['country'] ?? '',
'region_name' => $entry['regionName'] ?? '',
'city' => $entry['city'] ?? '',
'latitude' => $entry['lat'] ?? '',
'longitude' => $entry['lon'] ?? '',
'time_zone' => $entry['timezone'] ?? '',
];
}
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int
{
return 65; // ip-api interval is 1 minute. Return 5 extra seconds just in case
}
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int
{
return 145; // ip-api limit is 150 requests per minute. Leave 5 less requests just in case
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpLocationResolver implements IpLocationResolverInterface
{
const SERVICE_PATTERN = 'http://freegeoip.net/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return json_decode((string) $response->getBody(), true);
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
}

View File

@@ -13,4 +13,18 @@ interface IpLocationResolverInterface
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int;
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int;
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
class IpApiLocationResolverTest extends TestCase
{
/**
* @var IpApiLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$actual = [
'countryCode' => 'bar',
'lat' => 5,
'lon' => 10,
];
$expected = [
'country_code' => 'bar',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => 5,
'longitude' => 10,
'time_zone' => '',
];
$response = new Response();
$response->getBody()->write(\json_encode($actual));
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
/**
* @test
*/
public function getApiIntervalReturnsExpectedValue()
{
$this->assertEquals(65, $this->ipResolver->getApiInterval());
}
/**
* @test
*/
public function getApiLimitReturnsExpectedValue()
{
$this->assertEquals(145, $this->ipResolver->getApiLimit());
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
class IpLocationResolverTest extends TestCase
{
/**
* @var IpLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$expected = [
'foo' => 'bar',
'baz' => 'foo',
];
$response = new Response();
$response->getBody()->write(json_encode($expected));
$response->getBody()->rewind();
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
}

View File

@@ -29,7 +29,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* name="short_code",
* type="string",
* nullable=false,
* length=10,
* length=255,
* unique=true
* )
*/

View File

@@ -78,19 +78,34 @@ final class ShortUrlMeta
throw ValidationException::fromInputFilter($inputFilter);
}
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE);
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null;
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
}
/**
* @param string|\DateTime|null $date
* @return \DateTime|null
*/
public function getValidSince()
private function parseDateField($date): ?\DateTime
{
if ($date === null || $date instanceof \DateTime) {
return $date;
}
if (\is_string($date)) {
return new \DateTime($date);
}
return null;
}
/**
* @return \DateTime|null
*/
public function getValidSince(): ?\DateTime
{
return $this->validSince;
}
@@ -103,7 +118,7 @@ final class ShortUrlMeta
/**
* @return \DateTime|null
*/
public function getValidUntil()
public function getValidUntil(): ?\DateTime
{
return $this->validUntil;
}

View File

@@ -51,16 +51,17 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(v) AS totalVisits')
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return \array_column($qb->getQuery()->getResult(), 0);
} elseif (\in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
}
if (\in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult();
}
@@ -92,7 +93,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
$qb->leftJoin('s.tags', 't');
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}
$conditions = [
$qb->expr()->like('s.originalUrl', ':searchPattern'),

View File

@@ -28,16 +28,18 @@ class TagService implements TagServiceInterface
* @return Tag[]
* @throws \UnexpectedValueException
*/
public function listTags()
public function listTags(): array
{
return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
/** @var Tag[] $tags */
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
return $tags;
}
/**
* @param array $tagNames
* @return void
*/
public function deleteTags(array $tagNames)
public function deleteTags(array $tagNames): void
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
@@ -50,7 +52,7 @@ class TagService implements TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames)
public function createTags(array $tagNames): Collection
{
$tags = $this->tagNamesToEntities($this->em, $tagNames);
$this->em->flush();
@@ -65,7 +67,7 @@ class TagService implements TagServiceInterface
* @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
*/
public function renameTag($oldName, $newName)
public function renameTag($oldName, $newName): Tag
{
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */

View File

@@ -12,13 +12,13 @@ interface TagServiceInterface
/**
* @return Tag[]
*/
public function listTags();
public function listTags(): array;
/**
* @param string[] $tagNames
* @return void
*/
public function deleteTags(array $tagNames);
public function deleteTags(array $tagNames): void;
/**
* Provided a list of tag names, creates all that do not exist yet
@@ -26,7 +26,7 @@ interface TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames);
public function createTags(array $tagNames): Collection;
/**
* @param string $oldName
@@ -34,5 +34,5 @@ interface TagServiceInterface
* @return Tag
* @throws EntityDoesNotExistException
*/
public function renameTag($oldName, $newName);
public function renameTag($oldName, $newName): Tag;
}

View File

@@ -5,15 +5,17 @@ namespace ShlinkioTest\Shlink\Core\Repository;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class ShortUrlRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
ShortUrl::class,
Visit::class,
Tag::class,
];
/**
@@ -38,7 +40,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar')
->setShortCode('bar_very_long_text')
->setValidSince((new \DateTime())->add(new \DateInterval('P1M')));
$this->getEntityManager()->persist($bar);
@@ -79,4 +81,35 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertEquals($count, $this->repo->countList());
}
/**
* @test
*/
public function findListProperlyFiltersByTagAndSearchTerm()
{
$tag = new Tag('bar');
$this->getEntityManager()->persist($tag);
$foo = new ShortUrl();
$foo->setOriginalUrl('foo')
->setShortCode('foo')
->setTags(new ArrayCollection([$tag]));
$this->getEntityManager()->persist($foo);
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar_very_long_text');
$this->getEntityManager()->persist($bar);
$foo2 = new ShortUrl();
$foo2->setOriginalUrl('foo_2')
->setShortCode('foo_2');
$this->getEntityManager()->persist($foo2);
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, 'foo', ['bar']);
$this->assertCount(1, $result);
$this->assertSame($foo, $result[0]);
}
}

View File

@@ -9,7 +9,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class TagRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
Tag::class,
];

View File

@@ -12,7 +12,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class VisitRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
VisitLocation::class,
Visit::class,
ShortUrl::class,

View File

@@ -25,7 +25,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null)
public function create(\DateTime $expirationDate = null): ApiKey
{
$key = new ApiKey();
if ($expirationDate !== null) {
@@ -44,7 +44,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key
* @return bool
*/
public function check(string $key)
public function check(string $key): bool
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
@@ -58,7 +58,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @return ApiKey
* @throws InvalidArgumentException
*/
public function disable(string $key)
public function disable(string $key): ApiKey
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
@@ -77,10 +77,12 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys(bool $enabledOnly = false)
public function listKeys(bool $enabledOnly = false): array
{
$conditions = $enabledOnly ? ['enabled' => true] : [];
return $this->em->getRepository(ApiKey::class)->findBy($conditions);
/** @var ApiKey[] $apiKeys */
$apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions);
return $apiKeys;
}
/**
@@ -89,7 +91,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key
* @return ApiKey|null
*/
public function getByKey(string $key)
public function getByKey(string $key): ?ApiKey
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([

View File

@@ -14,7 +14,7 @@ interface ApiKeyServiceInterface
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null);
public function create(\DateTime $expirationDate = null): ApiKey;
/**
* Checks if provided key is a valid api key
@@ -22,7 +22,7 @@ interface ApiKeyServiceInterface
* @param string $key
* @return bool
*/
public function check(string $key);
public function check(string $key): bool;
/**
* Disables provided api key
@@ -31,7 +31,7 @@ interface ApiKeyServiceInterface
* @return ApiKey
* @throws InvalidArgumentException
*/
public function disable(string $key);
public function disable(string $key): ApiKey;
/**
* Lists all existing api keys
@@ -39,7 +39,7 @@ interface ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys(bool $enabledOnly = false);
public function listKeys(bool $enabledOnly = false): array;
/**
* Tries to find one API key by its key string
@@ -47,5 +47,5 @@ interface ApiKeyServiceInterface
* @param string $key
* @return ApiKey|null
*/
public function getByKey(string $key);
public function getByKey(string $key): ?ApiKey;
}

View File

@@ -4,4 +4,3 @@ parameters:
- module/Rest/src/Util/RestUtils.php
ignoreErrors:
- '#is not subtype of Throwable#'
- '#Cannot access offset#'