Compare commits

..

34 Commits

Author SHA1 Message Date
Alejandro Celaya
35950a6294 Added release title to changelog 2019-05-13 20:07:33 +02:00
Alejandro Celaya
c104eee2b1 Merge pull request #408 from acelaya/feature/improve-logs
Renamed Swoole logger to Access logger
2019-05-13 19:30:39 +02:00
Alejandro Celaya
f0972c6220 Removed optional dependency constraints used for no longer support PHP versions 2019-05-13 19:21:59 +02:00
Alejandro Celaya
42a5145895 Renamed Swoole logger to Access logger 2019-05-13 19:16:14 +02:00
Alejandro Celaya
8d412e7d4c Merge pull request #407 from acelaya/feature/edit-patch
Feature/edit patch
2019-05-05 10:22:52 +02:00
Alejandro Celaya
f45e34cfcf Documented deprecated endpoint 2019-05-05 09:52:49 +02:00
Alejandro Celaya
320c8e2d6b Ensured accepted methods on CORS requests are dynamically fetched from route match when possible 2019-05-05 09:45:35 +02:00
Alejandro Celaya
988de0b96e Updated edit short URL endpoint to be used with patch instead of put 2019-05-05 09:21:57 +02:00
Alejandro Celaya
25a785dfa7 Merge pull request #404 from acelaya/feature/config-post-processor
Feature/config post processor
2019-04-18 10:59:50 +02:00
Alejandro Celaya
c993bbd993 Updated changelog 2019-04-18 10:47:26 +02:00
Alejandro Celaya
479760c0ee Created config post processor that parses a simplified config to what shlink expects 2019-04-18 10:37:38 +02:00
Alejandro Celaya
e186237410 Merge pull request #403 from acelaya/feature/tweaks
Removed superfluous option from command tester
2019-04-14 22:28:00 +02:00
Alejandro Celaya
4084e3f0d8 Removed superfluous option from command tester 2019-04-14 22:20:58 +02:00
Alejandro Celaya
dddf64031f Merge pull request #402 from acelaya/feature/update-db-on-process
Feature/update db on process
2019-04-14 18:15:40 +02:00
Alejandro Celaya
8f1477e893 Updated changelog 2019-04-14 18:07:23 +02:00
Alejandro Celaya
4866fe241e Updated LocateVisitsCommand to update the database if needed 2019-04-14 18:00:19 +02:00
Alejandro Celaya
6613cb5c60 Updated amount of days to wait for the GeoLite2 database to be updated 2019-04-14 13:18:03 +02:00
Alejandro Celaya
0f48dd567f Registered GeolocationDbUpdater service and added callable which is invoked when db is going to be updated 2019-04-14 11:19:21 +02:00
Alejandro Celaya
b24511b7b5 Created service that updated GeoLite database when it is older than 7 days 2019-04-14 10:54:01 +02:00
Alejandro Celaya
df40199134 Renamed common config files so that they have the same preffix 2019-04-14 10:25:32 +02:00
Alejandro Celaya
935562acc9 Created exception to handle cases in which downloading a new geolite db fails 2019-04-14 10:10:20 +02:00
Alejandro Celaya
feb67e76f0 Updated commands 2019-04-14 09:10:00 +02:00
Alejandro Celaya
fdbe93f0fb Merge pull request #401 from acelaya/feature/templates
Feature/templates
2019-04-14 09:07:04 +02:00
Alejandro Celaya
f27058e255 Updated lang files 2019-04-14 08:59:55 +02:00
Alejandro Celaya
6ddbbb4ba0 Restyled error templates and removed copyright 2019-04-14 08:57:48 +02:00
Alejandro Celaya
ef32f2c129 Merge pull request #400 from acelaya/feature/simplify-cache
Dropped support for all caches other than APCu and Array
2019-04-11 22:56:54 +02:00
Alejandro Celaya
760bb2db2a Removed redis from dockerfiles for dev 2019-04-11 22:39:55 +02:00
Alejandro Celaya
68f38fd9fe Dropped support for all caches other than APCu and Array 2019-04-11 22:36:50 +02:00
Alejandro Celaya
5c6829fb62 Merge pull request #398 from acelaya/feature/issue-template
Created issue template with some reminders
2019-04-11 22:11:21 +02:00
Alejandro Celaya
91c48919c6 Excluded gihub dir from build 2019-04-11 22:01:35 +02:00
Alejandro Celaya
72313800fa Created issue template with some reminders 2019-04-11 21:57:12 +02:00
Alejandro Celaya
478d5a16fd Merge pull request #395 from acelaya/feature/drop-php7.1
Feature/drop php7.1
2019-04-09 22:51:17 +02:00
Alejandro Celaya
b8909d8043 Updated changelog 2019-04-09 22:43:01 +02:00
Alejandro Celaya
c2c659b0fe Dropped support for PHP 7.1 2019-04-09 22:40:15 +02:00
45 changed files with 885 additions and 367 deletions

6
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,6 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
Try to be polite, and understand it is impossible for a project to cover all use cases.
-->

View File

@@ -5,7 +5,6 @@ branches:
- /.*/
php:
- 7.1
- 7.2
- 7.3
@@ -14,7 +13,6 @@ services:
- postgresql
before_install:
- echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- yes | pecl install swoole
- phpenv config-rm xdebug.ini || return 0
@@ -50,10 +48,10 @@ deploy:
skip_cleanup: true
on:
tags: true
php: 7.1
php: 7.2
- provider: script
script: bash data/travis/trigger_docker_build.sh
skip_cleanup: true
on:
tags: true
php: 7.1
php: 7.2

View File

@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.17.0 - 2019-05-13
#### Added
* [#377](https://github.com/shlinkio/shlink/issues/377) Updated `visit:locate` command (formerly `visit:process`) to automatically update the GeoLite2 database if it is too old or it does not exist.
This simplifies processing visits in a container-based infrastructure, since a fresh container is capable of getting an updated version of the file by itself.
It also removes the need of asynchronously and programmatically updating the file, which deprecates the `visit:update-db` command.
* [#373](https://github.com/shlinkio/shlink/issues/373) Added support for a simplified config. Specially useful to use with the docker container.
#### Changed
* [#56](https://github.com/shlinkio/shlink/issues/56) Simplified supported cache, requiring APCu always.
### Deprecated
* [#406](https://github.com/shlinkio/shlink/issues/406) Deprecated `PUT /short-urls/{shortCode}` REST endpoint in favor of `PATCH /short-urls/{shortCode}`.
#### Removed
* [#385](https://github.com/shlinkio/shlink/issues/385) Dropped support for PHP 7.1
* [#379](https://github.com/shlinkio/shlink/issues/379) Removed copyright from error templates.
#### Fixed
* *Nothing*
## 1.16.3 - 2019-03-30
#### Added

View File

@@ -21,7 +21,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
First make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.1 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* PHP 7.2 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* MySQL, PostgreSQL or SQLite.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
@@ -74,7 +74,7 @@ Despite how you built the project, you are going to need to install it now, by f
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
@@ -188,21 +188,21 @@ There are a couple of time-consuming tasks that shlink expects you to do manuall
Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
* Resolve IP address locations: `/path/to/shlink/bin/cli visit:process`
* Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
* Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
When shlink is installed it downloads a fresh [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/) db file. Running this command will update this file.
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
*Any of those commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
* **For shlink older than v1.17.0**: Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
When shlink is installed it downloads a fresh [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/) db file. Running this command will update this file.
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews.
@@ -281,8 +281,8 @@ Available commands:
tag:list Lists existing tags.
tag:rename Renames one existing tag.
visit
visit:process Processes visits where location is not set yet
visit:update-db Updates the GeoLite2 database file used to geolocate IP addresses
visit:locate [visit:process] Resolves visits origin locations.
visit:update-db [DEPRECATED] Updates the GeoLite2 database file used to geolocate IP addresses
```
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View File

@@ -35,7 +35,8 @@ rsync -av * "${builtcontent}" \
--exclude=config/autoload/*local* \
--exclude=config/test \
--exclude=**/test* \
--exclude=build*
--exclude=build* \
--exclude=.github
cd "${builtcontent}"
# Install dependencies

View File

@@ -12,7 +12,7 @@
}
],
"require": {
"php": "^7.1",
"php": "^7.2",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2",
@@ -55,8 +55,8 @@
"filp/whoops": "^2.0",
"infection/infection": "^0.12.2",
"phpstan/phpstan": "^0.11.2",
"phpunit/phpcov": "^6.0 || ^5.0",
"phpunit/phpunit": "^8.0 || ^7.5",
"phpunit/phpcov": "^6.0",
"phpunit/phpunit": "^8.0",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~1.1.0",
"symfony/dotenv": "^4.2",

View File

@@ -28,7 +28,7 @@ return [
'max_files' => 30,
'formatter' => 'dashed',
],
'swoole_access_handler' => [
'access_handler' => [
'class' => StreamHandler::class,
'level' => Logger::INFO,
'stream' => 'php://stdout',
@@ -49,9 +49,9 @@ return [
'handlers' => ['shlink_rotating_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
'Swoole' => [
'handlers' => ['swoole_access_handler'],
'processors' => ['psr3'],
'Access' => [
'handlers' => ['access_handler'],
'processors' => ['exception_with_new_line', 'psr3'],
],
],
],
@@ -59,14 +59,14 @@ return [
'dependencies' => [
'factories' => [
'Logger_Shlink' => Common\Factory\LoggerFactory::class,
'Logger_Swoole' => Common\Factory\LoggerFactory::class,
'Logger_Access' => Common\Factory\LoggerFactory::class,
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger-name' => 'Logger_Swoole',
'logger-name' => 'Logger_Access',
],
],
],

View File

@@ -25,4 +25,6 @@ return (new ConfigAggregator\ConfigAggregator([
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
], 'data/cache/app_config.php'))->getMergedConfig();
], 'data/cache/app_config.php', [
Core\ConfigPostProcessor::class,
]))->getMergedConfig();

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@@ -1,8 +1,6 @@
FROM php:7.3.1-fpm-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV XDEBUG_VERSION "2.7.0RC1"
@@ -31,28 +29,6 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.tar.gz /tmp/phpredis.tar.gz
RUN mkdir -p /usr/src/php/ext/redis\
&& tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1
# configure and install
RUN docker-php-ext-configure redis\
&& docker-php-ext-install redis
# cleanup
RUN rm /tmp/phpredis.tar.gz
# Install memcached extension
RUN apk add --no-cache --virtual cyrus-sasl-dev
RUN apk add --no-cache --virtual libmemcached-dev
ADD https://github.com/php-memcached-dev/php-memcached/archive/v$MEMCACHED_VERSION.tar.gz /tmp/memcached.tar.gz
RUN mkdir -p /usr/src/php/ext/memcached\
&& tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1
# configure and install
RUN docker-php-ext-configure memcached\
&& docker-php-ext-install memcached
# cleanup
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\

View File

@@ -1,8 +1,6 @@
FROM php:7.3.1-cli-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV INOTIFY_VERSION 2.0.0
@@ -31,28 +29,6 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.tar.gz /tmp/phpredis.tar.gz
RUN mkdir -p /usr/src/php/ext/redis\
&& tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1
# configure and install
RUN docker-php-ext-configure redis\
&& docker-php-ext-install redis
# cleanup
RUN rm /tmp/phpredis.tar.gz
# Install memcached extension
RUN apk add --no-cache --virtual cyrus-sasl-dev
RUN apk add --no-cache --virtual libmemcached-dev
ADD https://github.com/php-memcached-dev/php-memcached/archive/v$MEMCACHED_VERSION.tar.gz /tmp/memcached.tar.gz
RUN mkdir -p /usr/src/php/ext/memcached\
&& tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1
# configure and install
RUN docker-php-ext-configure memcached\
&& docker-php-ext-install memcached
# cleanup
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\

View File

@@ -81,7 +81,7 @@
}
},
"put": {
"patch": {
"operationId": "editShortUrl",
"tags": [
"Short URLs"
@@ -169,6 +169,95 @@
}
},
"put": {
"deprecated": true,
"operationId": "editShortUrlPut",
"tags": [
"Short URLs"
],
"summary": "[DEPRECATED] Edit short URL",
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
}
}
}
}
}
},
"security": [
{
"ApiKey": []
},
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly updated."
},
"400": {
"description": "Provided meta arguments are invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
},
"delete": {
"operationId": "deleteShortUrl",
"tags": [

View File

@@ -35,7 +35,7 @@
"name": "X-Api-Key"
},
"Bearer": {
"description": "**[Deprecated]** The JWT identifying a previously authenticated API key",
"description": "**[DEPRECATED]** The JWT identifying a previously authenticated API key",
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
@@ -66,7 +66,7 @@
},
{
"name": "Authentication",
"description": "**[Deprecated]** Authentication-related endpoints"
"description": "**[DEPRECATED]** Authentication-related endpoints"
}
],

View File

@@ -14,7 +14,7 @@ return [
Command\ShortUrl\GeneratePreviewCommand::NAME => Command\ShortUrl\GeneratePreviewCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\UpdateDbCommand::NAME => Command\Visit\UpdateDbCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use GeoIp2\Database\Reader;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
@@ -19,6 +21,8 @@ return [
'factories' => [
Application::class => Factory\ApplicationFactory::class,
GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
@@ -26,7 +30,7 @@ return [
Command\ShortUrl\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\UpdateDbCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => InvokableFactory::class,
@@ -44,6 +48,8 @@ return [
],
ConfigAbstractFactory::class => [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class],
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
@@ -51,10 +57,11 @@ return [
Command\ShortUrl\GeneratePreviewCommand::class => [Service\ShortUrlService::class, PreviewGenerator::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\ProcessVisitsCommand::class => [
Command\Visit\LocateVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolverInterface::class,
Lock\Factory::class,
GeolocationDbUpdater::class,
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],

View File

@@ -3,7 +3,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
@@ -13,6 +15,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -20,9 +23,10 @@ use Symfony\Component\Lock\Factory as Locker;
use function sprintf;
class ProcessVisitsCommand extends Command
class LocateVisitsCommand extends Command
{
public const NAME = 'visit:process';
public const NAME = 'visit:locate';
public const ALIASES = ['visit:process'];
/** @var VisitServiceInterface */
private $visitService;
@@ -30,39 +34,48 @@ class ProcessVisitsCommand extends Command
private $ipLocationResolver;
/** @var Locker */
private $locker;
/** @var OutputInterface */
private $output;
/** @var GeolocationDbUpdaterInterface */
private $dbUpdater;
/** @var SymfonyStyle */
private $io;
/** @var ProgressBar */
private $progressBar;
public function __construct(
VisitServiceInterface $visitService,
IpLocationResolverInterface $ipLocationResolver,
Locker $locker
Locker $locker,
GeolocationDbUpdaterInterface $dbUpdater
) {
parent::__construct();
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->locker = $locker;
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Processes visits where location is not set yet');
->setAliases(self::ALIASES)
->setDescription('Resolves visits origin locations.');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$this->output = $output;
$io = new SymfonyStyle($input, $output);
$this->io = new SymfonyStyle($input, $output);
$lock = $this->locker->createLock(self::NAME);
if (! $lock->acquire()) {
$io->warning(sprintf('There is already an instance of the "%s" command in execution', self::NAME));
$this->io->warning(sprintf('There is already an instance of the "%s" command in execution', self::NAME));
return ExitCodes::EXIT_WARNING;
}
try {
$this->checkDbUpdate();
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
function (VisitLocation $location) use ($output) {
@@ -74,7 +87,7 @@ class ProcessVisitsCommand extends Command
}
);
$io->success('Finished processing all IPs');
$this->io->success('Finished processing all IPs');
} finally {
$lock->release();
return ExitCodes::EXIT_SUCCESS;
@@ -84,7 +97,7 @@ class ProcessVisitsCommand extends Command
public function getGeolocationDataForVisit(Visit $visit): Location
{
if (! $visit->hasRemoteAddr()) {
$this->output->writeln(
$this->io->writeln(
'<comment>Ignored visit with no IP address</comment>',
OutputInterface::VERBOSITY_VERBOSE
);
@@ -92,21 +105,51 @@ class ProcessVisitsCommand extends Command
}
$ipAddr = $visit->getRemoteAddr();
$this->output->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->output->writeln(' [<comment>Ignored localhost address</comment>]');
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
throw IpCannotBeLocatedException::forLocalhost();
}
try {
return $this->ipLocationResolver->resolveIpLocation($ipAddr);
} catch (WrongIpException $e) {
$this->output->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->output->isVerbose()) {
$this->getApplication()->renderException($e, $this->output);
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);
}
}
private function checkDbUpdate(): void
{
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
$this->io->writeln(
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading')
);
$this->progressBar = new ProgressBar($this->io);
}, function (int $total, int $downloaded) {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
});
if ($this->progressBar !== null) {
$this->progressBar->finish();
$this->io->newLine();
}
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
throw $e;
}
$this->io->newLine();
$this->io->writeln(
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>'
);
}
}
}

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
/** @deprecated */
class UpdateDbCommand extends Command
{
public const NAME = 'visit:update-db';
@@ -32,7 +33,7 @@ class UpdateDbCommand extends Command
{
$this
->setName(self::NAME)
->setDescription('Updates the GeoLite2 database file used to geolocate IP addresses')
->setDescription('[DEPRECATED] Updates the GeoLite2 database file used to geolocate IP addresses')
->setHelp(
'The GeoLite2 database is updated first Tuesday every month, so this command should be ideally run '
. 'every first Wednesday'

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use Throwable;
interface ExceptionInterface extends Throwable
{
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use RuntimeException;
use Throwable;
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
{
/** @var bool */
private $olderDbExists;
public function __construct(bool $olderDbExists, string $message = '', int $code = 0, Throwable $previous = null)
{
$this->olderDbExists = $olderDbExists;
parent::__construct($message, $code, $previous);
}
public static function create(bool $olderDbExists, Throwable $prev = null): self
{
return new self(
$olderDbExists,
'An error occurred while updating geolocation database, and an older version could not be found',
0,
$prev
);
}
public function olderDbExists(): bool
{
return $this->olderDbExists;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use InvalidArgumentException;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
/** @var DbUpdaterInterface */
private $dbUpdater;
/** @var Reader */
private $geoLiteDbReader;
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader)
{
$this->dbUpdater = $dbUpdater;
$this->geoLiteDbReader = $geoLiteDbReader;
}
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(callable $mustBeUpdated = null, callable $handleProgress = null): void
{
try {
$meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
}
} catch (InvalidArgumentException $e) {
// This is the exception thrown by the reader when the database file does not exist
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress);
}
}
private function buildIsTooOld(int $buildTimestamp): bool
{
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(
bool $olderDbExists,
callable $mustBeUpdated = null,
callable $handleProgress = null
): void {
if ($mustBeUpdated !== null) {
$mustBeUpdated($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($handleProgress);
} catch (RuntimeException $e) {
throw GeolocationDbUpdateFailedException::create($olderDbExists, $e);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
interface GeolocationDbUpdaterInterface
{
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(callable $mustBeUpdated = null, callable $handleProgress = null): void;
}

View File

@@ -34,7 +34,6 @@ class DisableKeyCommandTest extends TestCase
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
@@ -49,7 +48,6 @@ class DisableKeyCommandTest extends TestCase
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();

View File

@@ -34,9 +34,7 @@ class GenerateKeyCommandTest extends TestCase
{
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Generated API key: ', $output);
@@ -49,7 +47,6 @@ class GenerateKeyCommandTest extends TestCase
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',
]);
}

View File

@@ -31,9 +31,7 @@ class GenerateCharsetCommandTest extends TestCase
{
$prefix = 'Character set: ';
$this->commandTester->execute([
'command' => 'config:generate-charset',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length

View File

@@ -54,9 +54,7 @@ class GeneratePreviewCommandTest extends TestCase
$generatePreview2 = $this->previewGenerator->generatePreview('https://bar.com')->willReturn('');
$generatePreview3 = $this->previewGenerator->generatePreview('http://baz.com/something')->willReturn('');
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing URL http://foo.com', $output);
@@ -81,9 +79,7 @@ class GeneratePreviewCommandTest extends TestCase
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(count($items), substr_count($output, 'Error'));
}

View File

@@ -42,7 +42,6 @@ class GenerateShortUrlCommandTest extends TestCase
);
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--maxVisits' => '3',
]);
@@ -58,10 +57,7 @@ class GenerateShortUrlCommandTest extends TestCase
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/invalid',
]);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
'Provided URL "http://domain.com/invalid" is invalid.',
@@ -82,7 +78,6 @@ class GenerateShortUrlCommandTest extends TestCase
)->willReturn((new ShortUrl(''))->setShortCode('abc123'));
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
]);

View File

@@ -45,10 +45,7 @@ class GetVisitsCommandTest extends TestCase
new Paginator(new ArrayAdapter([]))
)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
}
/** @test */
@@ -65,7 +62,6 @@ class GetVisitsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
'--startDate' => $startDate,
'--endDate' => $endDate,
@@ -84,10 +80,7 @@ class GetVisitsCommandTest extends TestCase
]))
)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('Spain', $output);

View File

@@ -37,7 +37,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
}
/** @test */
@@ -54,7 +54,7 @@ class ListShortUrlsCommandTest extends TestCase
})->shouldBeCalledTimes(3);
$this->commandTester->setInputs(['y', 'y', 'n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Continue with page 2?', $output);
@@ -75,7 +75,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('url_1', $output);
@@ -95,10 +95,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--page' => $page,
]);
$this->commandTester->execute(['--page' => $page]);
}
/** @test */
@@ -108,10 +105,7 @@ class ListShortUrlsCommandTest extends TestCase
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--showTags' => true,
]);
$this->commandTester->execute(['--showTags' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tags', $output);
}

View File

@@ -41,10 +41,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
@@ -56,10 +53,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
}
@@ -71,10 +65,7 @@ class ResolveUrlCommandTest extends TestCase
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output);
}

View File

@@ -6,7 +6,9 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
@@ -24,7 +26,7 @@ use Symfony\Component\Lock;
use function array_shift;
use function sprintf;
class ProcessVisitsCommandTest extends TestCase
class LocateVisitsCommandTest extends TestCase
{
/** @var CommandTester */
private $commandTester;
@@ -36,11 +38,14 @@ class ProcessVisitsCommandTest extends TestCase
private $locker;
/** @var ObjectProphecy */
private $lock;
/** @var ObjectProphecy */
private $dbUpdater;
public function setUp(): void
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->locker = $this->prophesize(Lock\Factory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
@@ -49,10 +54,11 @@ class ProcessVisitsCommandTest extends TestCase
});
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$command = new ProcessVisitsCommand(
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
$this->locker->reveal()
$this->locker->reveal(),
$this->dbUpdater->reveal()
);
$app = new Application();
$app->add($command);
@@ -79,9 +85,7 @@ class ProcessVisitsCommandTest extends TestCase
Location::emptyInstance()
);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
@@ -111,9 +115,7 @@ class ProcessVisitsCommandTest extends TestCase
Location::emptyInstance()
);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString($message, $output);
@@ -150,9 +152,7 @@ class ProcessVisitsCommandTest extends TestCase
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
@@ -170,16 +170,51 @@ class ProcessVisitsCommandTest extends TestCase
});
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
sprintf('There is already an instance of the "%s" command', ProcessVisitsCommand::NAME),
sprintf('There is already an instance of the "%s" command', LocateVisitsCommand::NAME),
$output
);
$locateVisits->shouldNotHaveBeenCalled();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideParams
*/
public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
{
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
});
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists) {
[$mustBeUpdated, $handleProgress] = $args;
$mustBeUpdated($olderDbExists);
$handleProgress(100, 50);
throw GeolocationDbUpdateFailedException::create($olderDbExists);
}
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
$output
);
$this->assertStringContainsString($expectedMessage, $output);
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideParams(): iterable
{
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception;
use Exception;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideOlderDbExists
*/
public function constructCreatesExceptionWithDefaultArgs(bool $olderDbExists): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals('', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
public function provideOlderDbExists(): iterable
{
yield 'with older DB' => [true];
yield 'without older DB' => [false];
}
/**
* @test
* @dataProvider provideConstructorArgs
*/
public function constructCreatesException(bool $olderDbExists, string $message, int $code, ?Throwable $prev): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists, $message, $code, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals($message, $e->getMessage());
$this->assertEquals($code, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function provideConstructorArgs(): iterable
{
yield [true, 'This is a nice error message', 99, new Exception('prev')];
yield [false, 'Another message', 0, new RuntimeException('prev')];
yield [true, 'An yet another message', -50, null];
}
/**
* @test
* @dataProvider provideCreateArgs
*/
public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void
{
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals(
'An error occurred while updating geolocation database, and an older version could not be found',
$e->getMessage()
);
$this->assertEquals(0, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function provideCreateArgs(): iterable
{
yield 'older DB and no prev' => [true, null];
yield 'older DB and prev' => [true, new RuntimeException('prev')];
yield 'no older DB and no prev' => [false, null];
yield 'no older DB and prev' => [false, new Exception('prev')];
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use InvalidArgumentException;
use MaxMind\Db\Reader\Metadata;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Throwable;
use function Functional\map;
use function range;
class GeolocationDbUpdaterTest extends TestCase
{
/** @var GeolocationDbUpdater */
private $geolocationDbUpdater;
/** @var ObjectProphecy */
private $dbUpdater;
/** @var ObjectProphecy */
private $geoLiteDbReader;
public function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal()
);
}
/** @test */
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{
$mustBeUpdated = function () {
$this->assertTrue(true);
};
$getMeta = $this->geoLiteDbReader->metadata()->willThrow(InvalidArgumentException::class);
$prev = new RuntimeException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated);
$this->assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertFalse($e->olderDbExists());
}
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideBigDays
*/
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
{
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$prev = new RuntimeException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate();
$this->assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertTrue($e->olderDbExists());
}
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldHaveBeenCalledOnce();
}
public function provideBigDays(): iterable
{
yield [36];
yield [50];
yield [75];
yield [100];
}
/**
* @test
* @dataProvider provideSmallDays
*/
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void
{
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function () {
});
$this->geolocationDbUpdater->checkDbUpdate();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldNotHaveBeenCalled();
}
public function provideSmallDays(): iterable
{
return map(range(0, 34), function (int $days) {
return [$days];
});
}
}

View File

@@ -5,98 +5,19 @@ namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Memcached;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use function sys_get_temp_dir;
class CacheFactory implements FactoryInterface
{
private const VALID_CACHE_ADAPTERS = [
Cache\ApcuCache::class,
Cache\ArrayCache::class,
Cache\FilesystemCache::class,
Cache\PhpFileCache::class,
Cache\MemcachedCache::class,
];
private const DEFAULT_MEMCACHED_PORT = 11211;
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @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 ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): Cache\Cache
{
$appOptions = $container->get(AppOptions::class);
$adapter = $this->getAdapter($container);
$adapter = env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
$adapter->setNamespace((string) $appOptions);
return $adapter;
}
private function getAdapter(ContainerInterface $container): Cache\CacheProvider
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache']['adapter']) && contains(self::VALID_CACHE_ADAPTERS, $config['cache']['adapter'])) {
return $this->resolveCacheAdapter($config['cache']);
}
// If the adapter has not been set in config, create one based on environment
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
private function resolveCacheAdapter(array $cacheConfig): Cache\CacheProvider
{
switch ($cacheConfig['adapter']) {
case Cache\ArrayCache::class:
case Cache\ApcuCache::class:
return new $cacheConfig['adapter']();
case Cache\FilesystemCache::class:
case Cache\PhpFileCache::class:
return new $cacheConfig['adapter']($cacheConfig['options']['dir'] ?? sys_get_temp_dir());
case Cache\MemcachedCache::class:
$cache = new Cache\MemcachedCache();
$cache->setMemcached($this->buildMemcached($cacheConfig));
return $cache;
default:
return new Cache\ArrayCache();
}
}
private function buildMemcached(array $cacheConfig): Memcached
{
$memcached = new Memcached();
$servers = $cacheConfig['options']['servers'] ?? [];
foreach ($servers as $server) {
$this->addMemcachedServer($memcached, $server);
}
return $memcached;
}
private function addMemcachedServer(Memcached $memcached, array $server): void
{
if (! isset($server['host'])) {
return;
}
$port = (int) ($server['port'] ?? self::DEFAULT_MEMCACHED_PORT);
$memcached->addServer($server['host'], $port);
}
}

View File

@@ -5,27 +5,26 @@ namespace ShlinkioTest\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\FilesystemCache;
use Doctrine\Common\Cache\MemcachedCache;
use Doctrine\Common\Cache\RedisCache;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\ServiceManager;
use function count;
use function putenv;
use function realpath;
use function sys_get_temp_dir;
class CacheFactoryTest extends TestCase
{
/** @var CacheFactory */
private $factory;
/** @var ServiceManager */
private $sm;
public function setUp(): void
{
$this->factory = new CacheFactory();
$this->sm = new ServiceManager(['services' => [
AppOptions::class => new AppOptions(),
]]);
}
public static function tearDownAfterClass(): void
@@ -34,76 +33,18 @@ class CacheFactoryTest extends TestCase
}
/** @test */
public function productionReturnsApcAdapter()
public function productionReturnsApcAdapter(): void
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(), '');
$instance = ($this->factory)($this->sm, '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
/** @test */
public function developmentReturnsArrayAdapter()
public function developmentReturnsArrayAdapter(): void
{
putenv('APP_ENV=dev');
$instance = $this->factory->__invoke($this->createSM(), '');
$instance = ($this->factory)($this->sm, '');
$this->assertInstanceOf(ArrayCache::class, $instance);
}
/** @test */
public function adapterDefinedInConfigIgnoresEnvironment()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(ArrayCache::class), '');
$this->assertInstanceOf(ArrayCache::class, $instance);
}
/** @test */
public function invalidAdapterDefinedInConfigFallbacksToEnvironment()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(RedisCache::class), '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
/** @test */
public function filesystemCacheAdaptersReadDirOption()
{
$dir = realpath(sys_get_temp_dir());
/** @var FilesystemCache $instance */
$instance = $this->factory->__invoke($this->createSM(FilesystemCache::class, ['dir' => $dir]), '');
$this->assertInstanceOf(FilesystemCache::class, $instance);
$this->assertEquals($dir, $instance->getDirectory());
}
/** @test */
public function memcachedCacheAdaptersReadServersOption()
{
$servers = [
[
'host' => '1.2.3.4',
'port' => 123,
],
[
'host' => '4.3.2.1',
'port' => 321,
],
];
/** @var MemcachedCache $instance */
$instance = $this->factory->__invoke($this->createSM(MemcachedCache::class, ['servers' => $servers]), '');
$this->assertInstanceOf(MemcachedCache::class, $instance);
$this->assertEquals(count($servers), count($instance->getMemcached()->getServerList()));
}
private function createSM($cacheAdapter = null, array $options = [])
{
return new ServiceManager(['services' => [
'config' => isset($cacheAdapter) ? [
'cache' => [
'adapter' => $cacheAdapter,
'options' => $options,
],
] : [],
AppOptions::class => new AppOptions(),
]]);
}
}

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2017-10-13 12:29+0200\n"
"PO-Revision-Date: 2017-10-13 12:30+0200\n"
"POT-Creation-Date: 2019-04-14 08:58+0200\n"
"PO-Revision-Date: 2019-04-14 08:58+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.1\n"
"X-Generator: Poedit 2.1.1\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -39,3 +39,6 @@ msgstr "Esta URL acortada no parece ser válida."
msgid "Make sure you included all the characters, with no extra punctuation."
msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra."
msgid "Invalid URL"
msgstr "URL inválida"

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Shlinkio\Shlink\Installer\Util\PathCollection;
use Zend\Stdlib\ArrayUtils;
use function array_intersect_key;
use function array_key_exists;
use function Functional\contains;
use function Functional\reduce_left;
class ConfigPostProcessor
{
private const SIMPLIFIED_CONFIG_MAPPING = [
'disable_track_param' => ['app_options', 'disable_track_param'],
'short_domain_schema' => ['url_shortener', 'domain', 'schema'],
'short_domain_host' => ['url_shortener', 'domain', 'hostname'],
'validate_url' => ['url_shortener', 'validate_url'],
'not_found_redirect_to' => ['url_shortener', 'not_found_short_url', 'redirect_to'],
'db_config' => ['entity_manager', 'connection'],
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
'locale' => ['translator', 'locale'],
];
private const SIMPLIFIED_CONFIG_TOGGLES = [
'not_found_redirect_to' => ['url_shortener', 'not_found_short_url', 'enable_redirection'],
'delete_short_url_threshold' => ['delete_short_urls', 'check_visits_threshold'],
];
private const SIMPLIFIED_MERGEABLE_CONFIG = ['db_config'];
public function __invoke(array $config): array
{
$existingKeys = array_intersect_key($config, self::SIMPLIFIED_CONFIG_MAPPING);
return reduce_left($existingKeys, function ($value, string $key, $c, PathCollection $collection) {
$path = self::SIMPLIFIED_CONFIG_MAPPING[$key];
if (contains(self::SIMPLIFIED_MERGEABLE_CONFIG, $key)) {
$value = ArrayUtils::merge($collection->getValueInPath($path), $value);
}
$collection->setValueInPath($value, $path);
if (array_key_exists($key, self::SIMPLIFIED_CONFIG_TOGGLES)) {
$collection->setValueInPath(true, self::SIMPLIFIED_CONFIG_TOGGLES[$key]);
}
return $collection;
}, new PathCollection($config))->toArray();
}
}

View File

@@ -1,7 +1,7 @@
<?php $this->layout('ShlinkCore::layout/default') ?>
<?php $this->start('title') ?>
<?= $this->translate('URL Not Found') ?>
<?= $this->translate('Invalid URL') ?>
<?php $this->end() ?>
<?php $this->start('stylesheets') ?>

View File

@@ -5,28 +5,19 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="shortcut icon" href="/favicon.ico">
<style>
body {padding-top: 60px;}
.app {display: flex; min-height: 100vh; flex-direction: column;}
.app-content {flex: 1;}
.app-footer p {margin-bottom: 20px;}
html, body {height: 100%}
.app {height: 100vh; display: flex; align-items: center; justify-content: center; flex-flow: column;}
</style>
<?= $this->section('stylesheets', '') ?>
</head>
<body class="app">
<div class="app-content">
<body>
<div class="app">
<main class="container">
<?= $this->section('main', '') ?>
</main>
</div>
<footer class="app-footer">
<div class="container">
<hr>
<p>&copy; <?= date('Y') ?> <a href="https://shlink.io">Shlink</a></p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\ConfigPostProcessor;
use function array_merge;
class ConfigPostProcessorTest extends TestCase
{
private $postProcessor;
public function setUp(): void
{
$this->postProcessor = new ConfigPostProcessor();
}
/** @test */
public function properlyMapsSimplifiedConfig(): void
{
$config = [
'app_options' => [
'disable_track_param' => 'foo',
],
'entity_manager' => [
'connection' => [
'driver' => 'mysql',
'host' => 'shlink_db',
'port' => '3306',
],
],
];
$simplified = [
'disable_track_param' => 'bar',
'short_domain_schema' => 'https',
'short_domain_host' => 'doma.in',
'validate_url' => false,
'delete_short_url_threshold' => 50,
'locale' => 'es',
'not_found_redirect_to' => 'foobar.com',
'db_config' => [
'dbname' => 'shlink',
'user' => 'foo',
'password' => 'bar',
'port' => '1234',
],
];
$expected = [
'app_options' => [
'disable_track_param' => 'bar',
],
'entity_manager' => [
'connection' => [
'driver' => 'mysql',
'host' => 'shlink_db',
'dbname' => 'shlink',
'user' => 'foo',
'password' => 'bar',
'port' => '1234',
],
],
'url_shortener' => [
'domain' => [
'schema' => 'https',
'hostname' => 'doma.in',
],
'validate_url' => false,
'not_found_short_url' => [
'redirect_to' => 'foobar.com',
'enable_redirection' => true,
],
],
'translator' => [
'locale' => 'es',
],
'delete_short_urls' => [
'visits_threshold' => 50,
'check_visits_threshold' => true,
],
];
$result = ($this->postProcessor)(array_merge($config, $simplified));
$this->assertEquals(array_merge($expected, $simplified), $result);
}
}

View File

@@ -19,7 +19,7 @@ use function sprintf;
class EditShortUrlAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-urls/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT];
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT];
/** @var ShortUrlServiceInterface */
private $shortUrlService;

View File

@@ -4,29 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware;
use Fig\Http\Message\RequestMethodInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Authentication;
use Zend\Expressive\Router\RouteResult;
use function implode;
class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface
{
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param Request $request
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var Response $response */
$response = $handler->handle($request);
if (! $request->hasHeader('Origin')) {
return $response;
@@ -42,13 +32,28 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa
return $response;
}
// Add OPTIONS-specific headers
foreach ([
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be dynamic
// 'Access-Control-Allow-Methods' => $response->getHeaderLine('Allow'),
return $this->addOptionsHeaders($request, $response);
}
private function addOptionsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
/** @var RouteResult|null $matchedRoute */
$matchedRoute = $request->getAttribute(RouteResult::class);
$matchedMethods = $matchedRoute !== null ? $matchedRoute->getAllowedMethods() : [
self::METHOD_GET,
self::METHOD_POST,
self::METHOD_PUT,
self::METHOD_PATCH,
self::METHOD_DELETE,
self::METHOD_OPTIONS,
];
$corsHeaders = [
'Access-Control-Allow-Methods' => implode(',', $matchedMethods),
'Access-Control-Max-Age' => '1000',
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
] as $key => $value) {
];
foreach ($corsHeaders as $key => $value) {
$response = $response->withHeader($key, $value);
}

View File

@@ -10,63 +10,108 @@ use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
use function Zend\Stratigility\middleware;
class CrossDomainMiddlewareTest extends TestCase
{
/** @var CrossDomainMiddleware */
private $middleware;
/** @var ObjectProphecy */
private $delegate;
private $handler;
public function setUp(): void
{
$this->middleware = new CrossDomainMiddleware();
$this->delegate = $this->prophesize(RequestHandlerInterface::class);
$this->handler = $this->prophesize(RequestHandlerInterface::class);
}
/** @test */
public function nonCrossDomainRequestsAreNotAffected()
public function nonCrossDomainRequestsAreNotAffected(): void
{
$originalResponse = new Response();
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process(new ServerRequest(), $this->delegate->reveal());
$response = $this->middleware->process(new ServerRequest(), $this->handler->reveal());
$this->assertSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayNotHasKey('Access-Control-Expose-Headers', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
$this->assertArrayNotHasKey('Access-Control-Max-Age', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
}
/** @test */
public function anyRequestIncludesTheAllowAccessHeader()
public function anyRequestIncludesTheAllowAccessHeader(): void
{
$originalResponse = new Response();
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process(
(new ServerRequest())->withHeader('Origin', 'local'),
$this->delegate->reveal()
$this->handler->reveal()
);
$this->assertNotSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayHasKey('Access-Control-Expose-Headers', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
$this->assertArrayNotHasKey('Access-Control-Max-Age', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
}
/** @test */
public function optionsRequestIncludesMoreHeaders()
public function optionsRequestIncludesMoreHeaders(): void
{
$originalResponse = new Response();
$request = (new ServerRequest())->withMethod('OPTIONS')->withHeader('Origin', 'local');
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process($request, $this->delegate->reveal());
$response = $this->middleware->process($request, $this->handler->reveal());
$this->assertNotSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayHasKey('Access-Control-Expose-Headers', $headers);
$this->assertArrayHasKey('Access-Control-Allow-Methods', $headers);
$this->assertArrayHasKey('Access-Control-Max-Age', $headers);
$this->assertArrayHasKey('Access-Control-Allow-Headers', $headers);
}
/**
* @test
* @dataProvider provideRouteResults
*/
public function optionsRequestParsesRouteMatchToDetermineAllowedMethods(
?RouteResult $result,
string $expectedAllowedMethods
): void {
$originalResponse = new Response();
$request = (new ServerRequest())->withAttribute(RouteResult::class, $result)
->withMethod('OPTIONS')
->withHeader('Origin', 'local');
$this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process($request, $this->handler->reveal());
$this->assertEquals($response->getHeaderLine('Access-Control-Allow-Methods'), $expectedAllowedMethods);
}
public function provideRouteResults(): iterable
{
yield 'with no route result' => [null, 'GET,POST,PUT,PATCH,DELETE,OPTIONS'];
yield 'with failed route result' => [RouteResult::fromRouteFailure(['POST', 'GET']), 'POST,GET'];
yield 'with success route result' => [
RouteResult::fromRoute(
new Route('/', middleware(function () {
}), ['DELETE', 'PATCH', 'PUT'])
),
'DELETE,PATCH,PUT',
];
}
}