diff --git a/.gitattributes b/.gitattributes index 3946beb0..13bb2a91 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,4 @@ indocker export-ignore phpcs.xml export-ignore phpunit.xml.dist export-ignore phpunit-func.xml export-ignore +phpstan.neon diff --git a/.travis.yml b/.travis.yml index 4999fdb5..6e5357c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,7 @@ language: php branches: only: - - master - - develop + - /.*/ php: - 7 @@ -17,10 +16,12 @@ before_install: before_script: - composer self-update - composer install --no-interaction + - if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then composer global require --dev phpstan/phpstan:0.9.*; fi script: - mkdir build - composer check + - if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then ~/.composer/vendor/bin/phpstan analyse module/*/src/ --level=6 -c phpstan.neon; fi after_script: - vendor/bin/phpcov merge build --clover build/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3368f4b4..abb14ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## CHANGELOG +### 1.7.0 + +**Features** + +* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88) +* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108) + +**Enhancements:** + +* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113) +* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112) + +**Tasks** + +* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117) +* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115) +* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114) + ### 1.6.2 **Bugs** diff --git a/bin/cli b/bin/cli index 263df59e..ea8cb5c3 100755 --- a/bin/cli +++ b/bin/cli @@ -1,5 +1,7 @@ #!/usr/bin/env php [ Application::class => InstallApplicationFactory::class, Filesystem::class => InvokableFactory::class, - QuestionHelper::class => InvokableFactory::class, ], 'services' => [ 'config' => [ ConfigAbstractFactory::class => [ - DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] + DatabaseConfigCustomizer::class => [Filesystem::class] ], ], ], diff --git a/bin/update b/bin/update index d4203528..ce03fa16 100755 --- a/bin/update +++ b/bin/update @@ -1,9 +1,9 @@ #!/usr/bin/env php [ Application::class => InstallApplicationFactory::class, Filesystem::class => InvokableFactory::class, - QuestionHelper::class => InvokableFactory::class, ], 'services' => [ 'config' => [ ConfigAbstractFactory::class => [ - DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] + DatabaseConfigCustomizer::class => [Filesystem::class] ], ], ], diff --git a/composer.json b/composer.json index 8cbe3267..d961f5d5 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,13 @@ "php": "^7.0", "acelaya/ze-content-based-error-handler": "^2.0", "cocur/slugify": "^3.0", - "doctrine/annotations": "^1.4 <1.5", - "doctrine/cache": "^1.6 <1.7", - "doctrine/collections": "^1.4 <1.5", - "doctrine/common": "^2.7 <2.8", - "doctrine/dbal": "^2.5 <2.6", + "doctrine/annotations": "^1.4", + "doctrine/cache": "^1.6", + "doctrine/collections": "^1.4", + "doctrine/common": "^2.7", + "doctrine/dbal": "^2.5", "doctrine/migrations": "^1.4", - "doctrine/orm": "^2.5 <2.6", + "doctrine/orm": "^2.5", "endroid/qrcode": "^1.7", "firebase/php-jwt": "^4.0", "guzzlehttp/guzzle": "^6.2", @@ -29,7 +29,7 @@ "mikehaertl/phpwkhtmltopdf": "^2.2", "monolog/monolog": "^1.21", "roave/security-advisories": "dev-master", - "symfony/console": "^3.0", + "symfony/console": "^3.4", "symfony/filesystem": "^3.0", "symfony/process": "^3.0", "theorchard/monolog-cascade": "^0.4", @@ -40,8 +40,9 @@ "zendframework/zend-expressive-helpers": "^4.2", "zendframework/zend-expressive-platesrenderer": "^1.3", "zendframework/zend-i18n": "^2.7", + "zendframework/zend-inputfilter": "^2.8", "zendframework/zend-paginator": "^2.6", - "zendframework/zend-servicemanager": "^3.0", + "zendframework/zend-servicemanager": "^3.2", "zendframework/zend-stdlib": "^3.0" }, "require-dev": { @@ -50,9 +51,9 @@ "phpunit/phpcov": "^4.0", "phpunit/phpunit": "^6.0", "slevomat/coding-standard": "^4.0", - "squizlabs/php_codesniffer": "^3.1", + "squizlabs/php_codesniffer": "^3.1 <3.2", + "symfony/dotenv": "^3.4", "symfony/var-dumper": "^3.0", - "vlucas/phpdotenv": "^2.2", "zendframework/zend-expressive-tooling": "^0.4" }, "autoload": { @@ -102,7 +103,7 @@ "process-timeout": 0, "sort-packages": true, "platform": { - "php": "7.0" + "php": "7.0.8" } } } diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index 0f8163bf..2a32f31b 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -1,14 +1,14 @@ [ 'name' => 'Shlink', - 'version' => '1.2.0', - 'secret_key' => Common\env('SECRET_KEY'), + 'version' => '1.7.0', + 'secret_key' => env('SECRET_KEY'), ], ]; diff --git a/config/container.php b/config/container.php index 126351ae..ec249907 100644 --- a/config/container.php +++ b/config/container.php @@ -1,7 +1,7 @@ load(); + $dotenv = new Dotenv(); + $dotenv->load(__DIR__ . '/../.env'); } // Build container diff --git a/docs/swagger/paths/v1_short-codes.json b/docs/swagger/paths/v1_short-codes.json index bdeffe3f..c63a11a9 100644 --- a/docs/swagger/paths/v1_short-codes.json +++ b/docs/swagger/paths/v1_short-codes.json @@ -116,6 +116,7 @@ } } }, + "post": { "tags": [ "ShortCodes" @@ -140,6 +141,34 @@ "type": "string" } }, + { + "name": "validSince", + "in": "formData", + "description": "The date (in ISO-8601 format) from which this short code will be valid", + "required": false, + "type": "string" + }, + { + "name": "validUntil", + "in": "formData", + "description": "The date (in ISO-8601 format) until which this short code will be valid", + "required": false, + "type": "string" + }, + { + "name": "customSlug", + "in": "formData", + "description": "A unique custom slug to be used instead of the generated short code", + "required": false, + "type": "string" + }, + { + "name": "maxVisits", + "in": "formData", + "description": "The maximum number of allowed visits for this short code", + "required": false, + "type": "number" + }, { "$ref": "../parameters/Authorization.json" } diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}.json b/docs/swagger/paths/v1_short-codes_{shortCode}.json index 78e54f61..01fdd634 100644 --- a/docs/swagger/paths/v1_short-codes_{shortCode}.json +++ b/docs/swagger/paths/v1_short-codes_{shortCode}.json @@ -54,5 +54,69 @@ } } } + }, + + "put": { + "tags": [ + "ShortCodes" + ], + "summary": "Edit short code", + "description": "Update certain meta arguments from an existing short URL.", + "parameters": [ + { + "name": "shortCode", + "in": "path", + "type": "string", + "description": "The short code to edit.", + "required": true + }, + { + "name": "validSince", + "in": "formData", + "description": "The date (in ISO-8601 format) from which this short code will be valid", + "required": false, + "type": "string" + }, + { + "name": "validUntil", + "in": "formData", + "description": "The date (in ISO-8601 format) until which this short code will be valid", + "required": false, + "type": "string" + }, + { + "name": "maxVisits", + "in": "formData", + "description": "The maximum number of allowed visits for this short code", + "required": false, + "type": "number" + }, + { + "$ref": "../parameters/Authorization.json" + } + ], + "responses": { + "204": { + "description": "The short code has been properly updated." + }, + "400": { + "description": "Provided meta arguments are invalid.", + "schema": { + "$ref": "../definitions/Error.json" + } + }, + "404": { + "description": "No short URL was found for provided short code.", + "schema": { + "$ref": "../definitions/Error.json" + } + }, + "500": { + "description": "Unexpected error.", + "schema": { + "$ref": "../definitions/Error.json" + } + } + } } } diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index be1bda6b..c1f9043b 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -9,21 +9,25 @@ return [ 'cli' => [ 'locale' => Common\env('CLI_LOCALE', 'en'), 'commands' => [ - Command\Shortcode\GenerateShortcodeCommand::class, - Command\Shortcode\ResolveUrlCommand::class, - Command\Shortcode\ListShortcodesCommand::class, - Command\Shortcode\GetVisitsCommand::class, - Command\Shortcode\GeneratePreviewCommand::class, - Command\Visit\ProcessVisitsCommand::class, - Command\Config\GenerateCharsetCommand::class, - Command\Config\GenerateSecretCommand::class, - Command\Api\GenerateKeyCommand::class, - Command\Api\DisableKeyCommand::class, - Command\Api\ListKeysCommand::class, - Command\Tag\ListTagsCommand::class, - Command\Tag\CreateTagCommand::class, - Command\Tag\RenameTagCommand::class, - Command\Tag\DeleteTagsCommand::class, + Command\Shortcode\GenerateShortcodeCommand::NAME => Command\Shortcode\GenerateShortcodeCommand::class, + Command\Shortcode\ResolveUrlCommand::NAME => Command\Shortcode\ResolveUrlCommand::class, + Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class, + Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class, + Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class, + + Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class, + + Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class, + Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class, + + Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, + Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, + Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class, + + Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, + Command\Tag\CreateTagCommand::NAME => Command\Tag\CreateTagCommand::class, + Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, + Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, ], ], diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index 472ff447..2b8b5135 100644 Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po index db466f85..fe82aebe 100644 --- a/module/CLI/lang/es.po +++ b/module/CLI/lang/es.po @@ -1,15 +1,15 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2017-10-21 20:17+0200\n" -"PO-Revision-Date: 2017-10-21 20:19+0200\n" +"POT-Creation-Date: 2018-01-21 09:36+0100\n" +"PO-Revision-Date: 2018-01-21 09:39+0100\n" "Last-Translator: Alejandro Celaya \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.0.4\n" "X-Poedit-Basepath: ..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -24,8 +24,8 @@ msgid "The API key to disable" msgstr "La clave de API a deshabilitar" #, php-format -msgid "API key %s properly disabled" -msgstr "Clave de API %s deshabilitada correctamente" +msgid "API key \"%s\" properly disabled" +msgstr "Clave de API \"%s\" deshabilitada correctamente" #, php-format msgid "API key \"%s\" does not exist." @@ -39,8 +39,9 @@ msgstr "" "La fecha en la que la clave de API debe expirar. Utiliza cualquier valor " "válido en PHP." -msgid "Generated API key" -msgstr "Generada clave de API" +#, php-format +msgid "Generated API key: \"%s\"" +msgstr "Generada clave de API. \"%s\"" msgid "Lists all the available API keys." msgstr "Lista todas las claves de API disponibles." @@ -51,12 +52,12 @@ msgstr "Define si sólo las claves de API habilitadas deben ser devueltas." msgid "Key" msgstr "Clave" -msgid "Expiration date" -msgstr "Fecha de caducidad" - msgid "Is enabled" msgstr "Está habilitada" +msgid "Expiration date" +msgstr "Fecha de caducidad" + #, php-format msgid "" "Generates a character set sample just by shuffling the default one, \"%s\". " @@ -65,8 +66,9 @@ msgstr "" "Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s" "\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS" -msgid "Character set:" -msgstr "Grupo de caracteres:" +#, php-format +msgid "Character set: \"%s\"" +msgstr "Grupo de caracteres: \"%s\"" msgid "" "Generates a random secret string that can be used for JWT token encryption" @@ -74,8 +76,9 @@ msgstr "" "Genera una cadena de caracteres aleatoria que puede ser usada para cifrar " "tokens JWT" -msgid "Secret key:" -msgstr "Clave secreta:" +#, php-format +msgid "Secret key: \"%s\"" +msgstr "Clave secreta: \"%s\"" msgid "" "Processes and generates the previews for every URL, improving performance " @@ -125,17 +128,22 @@ msgid "If provided, this slug will be used instead of generating a short code" msgstr "" "Si se proporciona, este slug será usado en vez de generar un código corto" -msgid "A long URL was not provided. Which URL do you want to shorten?:" +msgid "This will limit the number of visits for this short URL." +msgstr "Esto limitará el número de visitas a esta URL acortada." + +#, fuzzy +#| msgid "A long URL was not provided. Which URL do you want to shorten?:" +msgid "A long URL was not provided. Which URL do you want to be shortened?" msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" msgid "A URL was not provided!" msgstr "¡No se ha proporcionado una URL!" -msgid "Processed URL:" -msgstr "URL procesada:" +msgid "Processed long URL:" +msgstr "URL larga procesada:" -msgid "Generated URL:" -msgstr "URL generada:" +msgid "Generated short URL:" +msgstr "URL corta generada:" #, php-format msgid "Provided URL \"%s\" is invalid. Try with a different one." @@ -166,8 +174,8 @@ msgid "Allows to filter visits, returning only those newer than end date" msgstr "" "Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate" -msgid "A short code was not provided. Which short code do you want to use?:" -msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?:" +msgid "A short code was not provided. Which short code do you want to use?" +msgstr "No se proporcionó un código corto. ¿Qué código corto deseas usar?" msgid "Referer" msgstr "Origen" @@ -222,8 +230,8 @@ msgstr "Número de visitas" msgid "Tags" msgstr "Etiquetas" -msgid "You have reached last page" -msgstr "Has alcanzado la última página" +msgid "Short codes properly listed" +msgstr "Códigos cortos correctamente listados" msgid "Continue with page" msgstr "Continuar con la página" @@ -234,13 +242,9 @@ msgstr "Devuelve la URL larga detrás de un código corto" msgid "The short code to parse" msgstr "El código corto a convertir" -msgid "A short code was not provided. Which short code do you want to parse?:" +msgid "A short code was not provided. Which short code do you want to parse?" msgstr "" -"No se proporcionó un código corto. ¿Qué código corto quieres convertir?:" - -#, php-format -msgid "No URL found for short code \"%s\"" -msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" +"No se proporcionó un código corto. ¿Qué código corto quieres convertir?" msgid "Long URL:" msgstr "URL larga:" @@ -262,8 +266,8 @@ msgstr "El nombre de las etiquetas a crear" msgid "You have to provide at least one tag name" msgstr "Debes proporcionar al menos un nombre de etiqueta" -msgid "Created tags" -msgstr "Etiquetas creadas" +msgid "Tags properly created" +msgstr "Etiquetas correctamente creadas" msgid "Deletes one or more tags." msgstr "Elimina una o más etiquetas." @@ -271,8 +275,8 @@ msgstr "Elimina una o más etiquetas." msgid "The name of the tags to delete" msgstr "El nombre de las etiquetas a eliminar" -msgid "Deleted tags" -msgstr "Etiquetas eliminadas" +msgid "Tags properly deleted" +msgstr "Etiquetas correctamente eliminadas" msgid "Lists existing tags." msgstr "Lista las etiquetas existentes." @@ -315,3 +319,15 @@ msgstr "Dirección localizada en \"%s\"" msgid "Finished processing all IPs" msgstr "Finalizado el procesado de todas las IPs" + +#~ msgid "You have reached last page" +#~ msgstr "Has alcanzado la última página" + +#~ msgid "No URL found for short code \"%s\"" +#~ msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" + +#~ msgid "Created tags" +#~ msgstr "Etiquetas creadas" + +#~ msgid "Deleted tags" +#~ msgstr "Etiquetas eliminadas" diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index e4390203..386f838d 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class DisableKeyCommand extends Command { + const NAME = 'api-key:disable'; + /** * @var ApiKeyServiceInterface */ @@ -30,7 +33,7 @@ class DisableKeyCommand extends Command public function configure() { - $this->setName('api-key:disable') + $this->setName(self::NAME) ->setDescription($this->translator->translate('Disables an API key.')) ->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable')); } @@ -38,18 +41,13 @@ class DisableKeyCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { $apiKey = $input->getArgument('apiKey'); + $io = new SymfonyStyle($input, $output); try { $this->apiKeyService->disable($apiKey); - $output->writeln(sprintf( - $this->translator->translate('API key %s properly disabled'), - '' . $apiKey . '' - )); + $io->success(sprintf($this->translator->translate('API key "%s" properly disabled'), $apiKey)); } catch (\InvalidArgumentException $e) { - $output->writeln(sprintf( - '' . $this->translator->translate('API key "%s" does not exist.') . '', - $apiKey - )); + $io->error(sprintf($this->translator->translate('API key "%s" does not exist.'), $apiKey)); } } } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 27006ceb..cfe747f0 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class GenerateKeyCommand extends Command { + const NAME = 'api-key:generate'; + /** * @var ApiKeyServiceInterface */ @@ -30,7 +33,7 @@ class GenerateKeyCommand extends Command public function configure() { - $this->setName('api-key:generate') + $this->setName(self::NAME) ->setDescription($this->translator->translate('Generates a new valid API key.')) ->addOption( 'expirationDate', @@ -44,6 +47,9 @@ class GenerateKeyCommand extends Command { $expirationDate = $input->getOption('expirationDate'); $apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null); - $output->writeln($this->translator->translate('Generated API key') . sprintf(': %s', $apiKey)); + + (new SymfonyStyle($input, $output))->success( + sprintf($this->translator->translate('Generated API key: "%s"'), $apiKey) + ); } } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index a32c9160..296b2455 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -6,14 +6,16 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class ListKeysCommand extends Command { + const NAME = 'api-key:list'; + /** * @var ApiKeyServiceInterface */ @@ -32,7 +34,7 @@ class ListKeysCommand extends Command public function configure() { - $this->setName('api-key:list') + $this->setName(self::NAME) ->setDescription($this->translator->translate('Lists all the available API keys.')) ->addOption( 'enabledOnly', @@ -44,78 +46,75 @@ class ListKeysCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $enabledOnly = $input->getOption('enabledOnly'); $list = $this->apiKeyService->listKeys($enabledOnly); - - $table = new Table($output); - if ($enabledOnly) { - $table->setHeaders([ - $this->translator->translate('Key'), - $this->translator->translate('Expiration date'), - ]); - } else { - $table->setHeaders([ - $this->translator->translate('Key'), - $this->translator->translate('Is enabled'), - $this->translator->translate('Expiration date'), - ]); - } + $rows = []; /** @var ApiKey $row */ foreach ($list as $row) { $key = $row->getKey(); $expiration = $row->getExpirationDate(); - $rowData = []; - $formatMethod = ! $row->isEnabled() - ? 'getErrorString' - : ($row->isExpired() ? 'getWarningString' : 'getSuccessString'); + $formatMethod = $this->determineFormatMethod($row); - if ($enabledOnly) { - $rowData[] = $this->{$formatMethod}($key); - } else { - $rowData[] = $this->{$formatMethod}($key); - $rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row)); + // Set columns for this row + $rowData = [$formatMethod($key)]; + if (! $enabledOnly) { + $rowData[] = $formatMethod($this->getEnabledSymbol($row)); } + $rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-'; - $rowData[] = isset($expiration) ? $expiration->format(\DateTime::ATOM) : '-'; - $table->addRow($rowData); + $rows[] = $rowData; } - $table->render(); + $io->table(array_filter([ + $this->translator->translate('Key'), + ! $enabledOnly ? $this->translator->translate('Is enabled') : null, + $this->translator->translate('Expiration date'), + ]), $rows); + } + + private function determineFormatMethod(ApiKey $apiKey): callable + { + if (! $apiKey->isEnabled()) { + return [$this, 'getErrorString']; + } + + return $apiKey->isExpired() ? [$this, 'getWarningString'] : [$this, 'getSuccessString']; } /** - * @param string $string + * @param string $value * @return string */ - protected function getErrorString($string) + private function getErrorString(string $value): string { - return sprintf('%s', $string); + return sprintf('%s', $value); } /** - * @param string $string + * @param string $value * @return string */ - protected function getSuccessString($string) + private function getSuccessString(string $value): string { - return sprintf('%s', $string); + return sprintf('%s', $value); } /** - * @param $string + * @param string $value * @return string */ - protected function getWarningString($string) + private function getWarningString(string $value): string { - return sprintf('%s', $string); + return sprintf('%s', $value); } /** * @param ApiKey $apiKey * @return string */ - protected function getEnabledSymbol(ApiKey $apiKey) + private function getEnabledSymbol(ApiKey $apiKey): string { return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++'; } diff --git a/module/CLI/src/Command/Config/GenerateCharsetCommand.php b/module/CLI/src/Command/Config/GenerateCharsetCommand.php index 189dedf3..caa64c28 100644 --- a/module/CLI/src/Command/Config/GenerateCharsetCommand.php +++ b/module/CLI/src/Command/Config/GenerateCharsetCommand.php @@ -7,10 +7,13 @@ use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class GenerateCharsetCommand extends Command { + const NAME = 'config:generate-charset'; + /** * @var TranslatorInterface */ @@ -24,7 +27,7 @@ class GenerateCharsetCommand extends Command public function configure() { - $this->setName('config:generate-charset') + $this->setName(self::NAME) ->setDescription(sprintf($this->translator->translate( 'Generates a character set sample just by shuffling the default one, "%s". ' . 'Then it can be set in the SHORTCODE_CHARS environment variable' @@ -34,6 +37,8 @@ class GenerateCharsetCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { $charSet = str_shuffle(UrlShortener::DEFAULT_CHARS); - $output->writeln($this->translator->translate('Character set:') . sprintf(' %s', $charSet)); + (new SymfonyStyle($input, $output))->success( + \sprintf($this->translator->translate('Character set: "%s"'), $charSet) + ); } } diff --git a/module/CLI/src/Command/Config/GenerateSecretCommand.php b/module/CLI/src/Command/Config/GenerateSecretCommand.php index 6bb1e232..684d6c8f 100644 --- a/module/CLI/src/Command/Config/GenerateSecretCommand.php +++ b/module/CLI/src/Command/Config/GenerateSecretCommand.php @@ -7,12 +7,15 @@ use Shlinkio\Shlink\Common\Util\StringUtilsTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class GenerateSecretCommand extends Command { use StringUtilsTrait; + const NAME = 'config:generate-secret'; + /** * @var TranslatorInterface */ @@ -26,7 +29,7 @@ class GenerateSecretCommand extends Command public function configure() { - $this->setName('config:generate-secret') + $this->setName(self::NAME) ->setDescription($this->translator->translate( 'Generates a random secret string that can be used for JWT token encryption' )); @@ -35,6 +38,8 @@ class GenerateSecretCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { $secret = $this->generateRandomString(32); - $output->writeln($this->translator->translate('Secret key:') . sprintf(' %s', $secret)); + (new SymfonyStyle($input, $output))->success( + sprintf($this->translator->translate('Secret key: "%s"'), $secret) + ); } } diff --git a/module/CLI/src/Command/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php index 6658678d..ef08b460 100644 --- a/module/CLI/src/Command/Install/InstallCommand.php +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -3,18 +3,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Install; -use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManagerInterface; use Shlinkio\Shlink\CLI\Install\Plugin; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Helper\ProcessHelper; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; use Zend\Config\Writer\WriterInterface; @@ -24,17 +25,9 @@ class InstallCommand extends Command const GENERATED_CONFIG_PATH = 'config/params/generated_config.php'; /** - * @var InputInterface + * @var SymfonyStyle */ - private $input; - /** - * @var OutputInterface - */ - private $output; - /** - * @var QuestionHelper - */ - private $questionHelper; + private $io; /** * @var ProcessHelper */ @@ -48,7 +41,7 @@ class InstallCommand extends Command */ private $filesystem; /** - * @var ConfigCustomizerPluginManagerInterface + * @var ConfigCustomizerManagerInterface */ private $configCustomizers; /** @@ -60,13 +53,14 @@ class InstallCommand extends Command * InstallCommand constructor. * @param WriterInterface $configWriter * @param Filesystem $filesystem + * @param ConfigCustomizerManagerInterface $configCustomizers * @param bool $isUpdate * @throws LogicException */ public function __construct( WriterInterface $configWriter, Filesystem $filesystem, - ConfigCustomizerPluginManagerInterface $configCustomizers, + ConfigCustomizerManagerInterface $configCustomizers, $isUpdate = false ) { parent::__construct(); @@ -83,30 +77,34 @@ class InstallCommand extends Command ->setDescription('Installs or updates Shlink'); } + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return void + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ public function execute(InputInterface $input, OutputInterface $output) { - $this->input = $input; - $this->output = $output; - $this->questionHelper = $this->getHelper('question'); - $this->processHelper = $this->getHelper('process'); + $this->io = new SymfonyStyle($input, $output); - $output->writeln([ + $this->io->writeln([ 'Welcome to Shlink!!', 'This will guide you through the installation process.', ]); // Check if a cached config file exists and drop it if so if ($this->filesystem->exists('data/cache/app_config.php')) { - $output->write('Deleting old cached config...'); + $this->io->write('Deleting old cached config...'); try { $this->filesystem->remove('data/cache/app_config.php'); - $output->writeln(' Success'); + $this->io->writeln(' Success'); } catch (IOException $e) { - $output->writeln( - ' Failed! You will have to manually delete the data/cache/app_config.php file to get' - . ' new config applied.' + $this->io->error( + 'Failed! You will have to manually delete the data/cache/app_config.php file to' + . ' get new config applied.' ); - if ($output->isVerbose()) { + if ($this->io->isVerbose()) { $this->getApplication()->renderException($e, $output); } return; @@ -118,56 +116,65 @@ class InstallCommand extends Command // Ask for custom config params foreach ([ - Plugin\DatabaseConfigCustomizerPlugin::class, - Plugin\UrlShortenerConfigCustomizerPlugin::class, - Plugin\LanguageConfigCustomizerPlugin::class, - Plugin\ApplicationConfigCustomizerPlugin::class, + Plugin\DatabaseConfigCustomizer::class, + Plugin\UrlShortenerConfigCustomizer::class, + Plugin\LanguageConfigCustomizer::class, + Plugin\ApplicationConfigCustomizer::class, ] as $pluginName) { - /** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */ + /** @var Plugin\ConfigCustomizerInterface $configCustomizer */ $configCustomizer = $this->configCustomizers->get($pluginName); - $configCustomizer->process($input, $output, $config); + $configCustomizer->process($this->io, $config); } // Generate config params files $this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false); - $output->writeln(['Custom configuration properly generated!', '']); + $this->io->writeln(['Custom configuration properly generated!', '']); // If current command is not update, generate database if (! $this->isUpdate) { - $this->output->writeln('Initializing database...'); + $this->io->write('Initializing database...'); if (! $this->runCommand( 'php vendor/bin/doctrine.php orm:schema-tool:create', - 'Error generating database.' + 'Error generating database.', + $output )) { return; } } // Run database migrations - $output->writeln('Updating database...'); - if (! $this->runCommand('php vendor/bin/doctrine-migrations migrations:migrate', 'Error updating database.')) { + $this->io->write('Updating database...'); + if (! $this->runCommand( + 'php vendor/bin/doctrine-migrations migrations:migrate', + 'Error updating database.', + $output + )) { return; } // Generate proxies - $output->writeln('Generating proxies...'); - if (! $this->runCommand('php vendor/bin/doctrine.php orm:generate-proxies', 'Error generating proxies.')) { + $this->io->write('Generating proxies...'); + if (! $this->runCommand( + 'php vendor/bin/doctrine.php orm:generate-proxies', + 'Error generating proxies.', + $output + )) { return; } + + $this->io->success('Installation complete!'); } /** * @return CustomizableAppConfig * @throws RuntimeException */ - private function importConfig() + private function importConfig(): CustomizableAppConfig { $config = new CustomizableAppConfig(); // Ask the user if he/she wants to import an older configuration - $importConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Do you want to import previous configuration? (Y/n): ' - )); + $importConfig = $this->io->confirm('Do you want to import configuration from previous installation?'); if (! $importConfig) { return $config; } @@ -175,17 +182,16 @@ class InstallCommand extends Command // Ask the user for the older shlink path $keepAsking = true; do { - $config->setImportedInstallationPath($this->ask( + $config->setImportedInstallationPath($this->io->ask( 'Previous shlink installation path from which to import config' )); $configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH; $configExists = $this->filesystem->exists($configFile); if (! $configExists) { - $keepAsking = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Provided path does not seem to be a valid shlink root path. ' - . 'Do you want to try another path? (Y/n): ' - )); + $keepAsking = $this->io->confirm( + 'Provided path does not seem to be a valid shlink root path. Do you want to try another path?' + ); } } while (! $configExists && $keepAsking); @@ -199,51 +205,31 @@ class InstallCommand extends Command return $config; } - /** - * @param string $text - * @param string|null $default - * @param bool $allowEmpty - * @return string - * @throws RuntimeException - */ - private function ask($text, $default = null, $allowEmpty = false) - { - if ($default !== null) { - $text .= ' (defaults to ' . $default . ')'; - } - do { - $value = $this->questionHelper->ask($this->input, $this->output, new Question( - '' . $text . ': ', - $default - )); - if (empty($value) && ! $allowEmpty) { - $this->output->writeln('Value can\'t be empty'); - } - } while (empty($value) && $default === null && ! $allowEmpty); - - return $value; - } - /** * @param string $command * @param string $errorMessage + * @param OutputInterface $output * @return bool + * @throws LogicException + * @throws InvalidArgumentException */ - private function runCommand($command, $errorMessage) + private function runCommand($command, $errorMessage, OutputInterface $output): bool { - $process = $this->processHelper->run($this->output, $command); + if ($this->processHelper === null) { + $this->processHelper = $this->getHelper('process'); + } + + $process = $this->processHelper->run($output, $command); if ($process->isSuccessful()) { - $this->output->writeln(' Success!'); + $this->io->writeln(' Success!'); return true; } - if ($this->output->isVerbose()) { + if ($this->io->isVerbose()) { return false; } - $this->output->writeln( - ' ' . $errorMessage . ' Run this command with -vvv to see specific error info.' - ); + $this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.'); return false; } } diff --git a/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php index 23579931..6fa0a487 100644 --- a/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php +++ b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php @@ -9,10 +9,13 @@ use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class GeneratePreviewCommand extends Command { + const NAME = 'shortcode:process-previews'; + /** * @var PreviewGeneratorInterface */ @@ -39,7 +42,7 @@ class GeneratePreviewCommand extends Command public function configure() { - $this->setName('shortcode:process-previews') + $this->setName(self::NAME) ->setDescription( $this->translator->translate( 'Processes and generates the previews for every URL, improving performance for later web requests.' @@ -59,22 +62,20 @@ class GeneratePreviewCommand extends Command } } while ($page <= $shortUrls->count()); - $output->writeln('' . $this->translator->translate('Finished processing all URLs') . ''); + (new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs')); } protected function processUrl($url, OutputInterface $output) { try { - $output->write(sprintf($this->translator->translate('Processing URL %s...'), $url)); + $output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url)); $this->previewGenerator->generatePreview($url); $output->writeln($this->translator->translate(' Success!')); } catch (PreviewGenerationException $e) { - $messages = [' ' . $this->translator->translate('Error') . '']; + $output->writeln(' ' . $this->translator->translate('Error') . ''); if ($output->isVerbose()) { - $messages[] = '' . $e->__toString() . ''; + $this->getApplication()->renderException($e, $output); } - - $output->writeln($messages); } } } diff --git a/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php index e685e876..f4c56f98 100644 --- a/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php +++ b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php @@ -7,17 +7,18 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\Diactoros\Uri; use Zend\I18n\Translator\TranslatorInterface; class GenerateShortcodeCommand extends Command { + const NAME = 'shortcode:generate'; + /** * @var UrlShortenerInterface */ @@ -44,7 +45,7 @@ class GenerateShortcodeCommand extends Command public function configure() { - $this->setName('shortcode:generate') + $this->setName(self::NAME) ->setDescription( $this->translator->translate('Generates a short code for provided URL and returns the short URL') ) @@ -73,19 +74,15 @@ class GenerateShortcodeCommand extends Command public function interact(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $longUrl = $input->getArgument('longUrl'); if (! empty($longUrl)) { return; } - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question(sprintf( - '%s ', - $this->translator->translate('A long URL was not provided. Which URL do you want to shorten?:') - )); - - $longUrl = $helper->ask($input, $output, $question); + $longUrl = $io->ask( + $this->translator->translate('A long URL was not provided. Which URL do you want to be shortened?') + ); if (! empty($longUrl)) { $input->setArgument('longUrl', $longUrl); } @@ -93,23 +90,24 @@ class GenerateShortcodeCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $longUrl = $input->getArgument('longUrl'); + if (empty($longUrl)) { + $io->error($this->translator->translate('A URL was not provided!')); + return; + } + $tags = $input->getOption('tags'); $processedTags = []; foreach ($tags as $key => $tag) { - $explodedTags = explode(',', $tag); - $processedTags = array_merge($processedTags, $explodedTags); + $explodedTags = \explode(',', $tag); + $processedTags = \array_merge($processedTags, $explodedTags); } $tags = $processedTags; $customSlug = $input->getOption('customSlug'); $maxVisits = $input->getOption('maxVisits'); try { - if (! isset($longUrl)) { - $output->writeln(sprintf('%s', $this->translator->translate('A URL was not provided!'))); - return; - } - $shortCode = $this->urlShortener->urlToShortCode( new Uri($longUrl), $tags, @@ -122,22 +120,20 @@ class GenerateShortcodeCommand extends Command ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); - $output->writeln([ - sprintf('%s %s', $this->translator->translate('Processed URL:'), $longUrl), - sprintf('%s %s', $this->translator->translate('Generated URL:'), $shortUrl), + $io->writeln([ + \sprintf('%s %s', $this->translator->translate('Processed long URL:'), $longUrl), + \sprintf('%s %s', $this->translator->translate('Generated short URL:'), $shortUrl), ]); } catch (InvalidUrlException $e) { - $output->writeln(sprintf( - '' . $this->translator->translate( - 'Provided URL "%s" is invalid. Try with a different one.' - ) . '', + $io->error(\sprintf( + $this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'), $longUrl )); } catch (NonUniqueSlugException $e) { - $output->writeln(sprintf( - '' . $this->translator->translate( + $io->error(\sprintf( + $this->translator->translate( 'Provided slug "%s" is already in use by another URL. Try with a different one.' - ) . '', + ), $customSlug )); } diff --git a/module/CLI/src/Command/Shortcode/GetVisitsCommand.php b/module/CLI/src/Command/Shortcode/GetVisitsCommand.php index 40192dde..61d044db 100644 --- a/module/CLI/src/Command/Shortcode/GetVisitsCommand.php +++ b/module/CLI/src/Command/Shortcode/GetVisitsCommand.php @@ -6,17 +6,17 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class GetVisitsCommand extends Command { + const NAME = 'shortcode:visits'; + /** * @var VisitsTrackerInterface */ @@ -30,12 +30,12 @@ class GetVisitsCommand extends Command { $this->visitsTracker = $visitsTracker; $this->translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() { - $this->setName('shortcode:visits') + $this->setName(self::NAME) ->setDescription( $this->translator->translate('Returns the detailed visits information for provided short code') ) @@ -65,14 +65,10 @@ class GetVisitsCommand extends Command return; } - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question(sprintf( - '%s ', - $this->translator->translate('A short code was not provided. Which short code do you want to use?:') - )); - - $shortCode = $helper->ask($input, $output, $question); + $io = new SymfonyStyle($input, $output); + $shortCode = $io->ask( + $this->translator->translate('A short code was not provided. Which short code do you want to use?') + ); if (! empty($shortCode)) { $input->setArgument('shortCode', $shortCode); } @@ -80,33 +76,32 @@ class GetVisitsCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $shortCode = $input->getArgument('shortCode'); $startDate = $this->getDateOption($input, 'startDate'); $endDate = $this->getDateOption($input, 'endDate'); $visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate)); - $table = new Table($output); - $table->setHeaders([ - $this->translator->translate('Referer'), - $this->translator->translate('Date'), - $this->translator->translate('Remote Address'), - $this->translator->translate('User agent'), - ]); - + $rows = []; foreach ($visits as $row) { $rowData = $row->jsonSerialize(); // Unset location info unset($rowData['visitLocation']); - $table->addRow(array_values($rowData)); + $rows[] = \array_values($rowData); } - $table->render(); + $io->table([ + $this->translator->translate('Referer'), + $this->translator->translate('Date'), + $this->translator->translate('Remote Address'), + $this->translator->translate('User agent'), + ], $rows); } protected function getDateOption(InputInterface $input, $key) { $value = $input->getOption($key); - if (isset($value)) { + if (! empty($value)) { $value = new \DateTime($value); } diff --git a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php index 4d402522..1e8ce12c 100644 --- a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php +++ b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php @@ -7,18 +7,18 @@ use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class ListShortcodesCommand extends Command { use PaginatorUtilsTrait; + const NAME = 'shortcode:list'; + /** * @var ShortUrlServiceInterface */ @@ -32,12 +32,12 @@ class ListShortcodesCommand extends Command { $this->shortUrlService = $shortUrlService; $this->translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() { - $this->setName('shortcode:list') + $this->setName(self::NAME) ->setDescription($this->translator->translate('List all short URLs')) ->addOption( 'page', @@ -81,19 +81,16 @@ class ListShortcodesCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('searchTerm'); $tags = $input->getOption('tags'); - $tags = ! empty($tags) ? explode(',', $tags) : []; + $tags = ! empty($tags) ? \explode(',', $tags) : []; $showTags = $input->getOption('showTags'); - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - do { $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); $page++; - $table = new Table($output); $headers = [ $this->translator->translate('Short code'), @@ -104,8 +101,8 @@ class ListShortcodesCommand extends Command if ($showTags) { $headers[] = $this->translator->translate('Tags'); } - $table->setHeaders($headers); + $rows = []; foreach ($result as $row) { $shortUrl = $row->jsonSerialize(); if ($showTags) { @@ -118,27 +115,23 @@ class ListShortcodesCommand extends Command unset($shortUrl['tags']); } - $table->addRow(array_values($shortUrl)); + $rows[] = \array_values($shortUrl); } - $table->render(); + $io->table($headers, $rows); if ($this->isLastPage($result)) { $continue = false; - $output->writeln( - sprintf('%s', $this->translator->translate('You have reached last page')) - ); + $io->success($this->translator->translate('Short codes properly listed')); } else { - $continue = $helper->ask($input, $output, new ConfirmationQuestion( - sprintf('' . $this->translator->translate( - 'Continue with page' - ) . ' %s? (y/N) ', $page), + $continue = $io->confirm( + \sprintf($this->translator->translate('Continue with page') . ' %s?', $page), false - )); + ); } } while ($continue); } - protected function processOrderBy(InputInterface $input) + private function processOrderBy(InputInterface $input) { $orderBy = $input->getOption('orderBy'); if (empty($orderBy)) { diff --git a/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php b/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php index fe79ee16..fcf62fbe 100644 --- a/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php +++ b/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php @@ -7,15 +7,16 @@ use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class ResolveUrlCommand extends Command { + const NAME = 'shortcode:parse'; + /** * @var UrlShortenerInterface */ @@ -34,7 +35,7 @@ class ResolveUrlCommand extends Command public function configure() { - $this->setName('shortcode:parse') + $this->setName(self::NAME) ->setDescription($this->translator->translate('Returns the long URL behind a short code')) ->addArgument( 'shortCode', @@ -50,14 +51,10 @@ class ResolveUrlCommand extends Command return; } - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question(sprintf( - '%s ', - $this->translator->translate('A short code was not provided. Which short code do you want to parse?:') - )); - - $shortCode = $helper->ask($input, $output, $question); + $io = new SymfonyStyle($input, $output); + $shortCode = $io->ask( + $this->translator->translate('A short code was not provided. Which short code do you want to parse?') + ); if (! empty($shortCode)) { $input->setArgument('shortCode', $shortCode); } @@ -65,27 +62,20 @@ class ResolveUrlCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $shortCode = $input->getArgument('shortCode'); try { $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); - if (! isset($longUrl)) { - $output->writeln(sprintf( - '' . $this->translator->translate('No URL found for short code "%s"') . '', - $shortCode - )); - return; - } - - $output->writeln(sprintf('%s %s', $this->translator->translate('Long URL:'), $longUrl)); + $output->writeln(\sprintf('%s %s', $this->translator->translate('Long URL:'), $longUrl)); } catch (InvalidShortCodeException $e) { - $output->writeln(sprintf('' . $this->translator->translate( - 'Provided short code "%s" has an invalid format.' - ) . '', $shortCode)); + $io->error( + \sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode) + ); } catch (EntityDoesNotExistException $e) { - $output->writeln(sprintf('' . $this->translator->translate( - 'Provided short code "%s" could not be found.' - ) . '', $shortCode)); + $io->error( + \sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode) + ); } } } diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 183dbe51..daf03d15 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class CreateTagCommand extends Command { + const NAME = 'tag:create'; + /** * @var TagServiceInterface */ @@ -31,7 +34,7 @@ class CreateTagCommand extends Command protected function configure() { $this - ->setName('tag:create') + ->setName(self::NAME) ->setDescription($this->translator->translate('Creates one or more tags.')) ->addOption( 'name', @@ -43,19 +46,15 @@ class CreateTagCommand extends Command protected function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $tagNames = $input->getOption('name'); + if (empty($tagNames)) { - $output->writeln(sprintf( - '%s', - $this->translator->translate('You have to provide at least one tag name') - )); + $io->warning($this->translator->translate('You have to provide at least one tag name')); return; } $this->tagService->createTags($tagNames); - $output->writeln($this->translator->translate('Created tags') . sprintf(': ["%s"]', implode( - '", "', - $tagNames - ))); + $io->success($this->translator->translate('Tags properly created')); } } diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index de7cf5f4..5da57ede 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -8,10 +8,13 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class DeleteTagsCommand extends Command { + const NAME = 'tag:delete'; + /** * @var TagServiceInterface */ @@ -31,7 +34,7 @@ class DeleteTagsCommand extends Command protected function configure() { $this - ->setName('tag:delete') + ->setName(self::NAME) ->setDescription($this->translator->translate('Deletes one or more tags.')) ->addOption( 'name', @@ -43,19 +46,15 @@ class DeleteTagsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $tagNames = $input->getOption('name'); + if (empty($tagNames)) { - $output->writeln(sprintf( - '%s', - $this->translator->translate('You have to provide at least one tag name') - )); + $io->warning($this->translator->translate('You have to provide at least one tag name')); return; } $this->tagService->deleteTags($tagNames); - $output->writeln($this->translator->translate('Deleted tags') . sprintf(': ["%s"]', implode( - '", "', - $tagNames - ))); + $io->success($this->translator->translate('Tags properly deleted')); } } diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 44a3f48e..ea860900 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -6,13 +6,15 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class ListTagsCommand extends Command { + const NAME = 'tag:list'; + /** * @var TagServiceInterface */ @@ -32,17 +34,14 @@ class ListTagsCommand extends Command protected function configure() { $this - ->setName('tag:list') + ->setName(self::NAME) ->setDescription($this->translator->translate('Lists existing tags.')); } protected function execute(InputInterface $input, OutputInterface $output) { - $table = new Table($output); - $table->setHeaders([$this->translator->translate('Name')]) - ->setRows($this->getTagsRows()); - - $table->render(); + $io = new SymfonyStyle($input, $output); + $io->table([$this->translator->translate('Name')], $this->getTagsRows()); } private function getTagsRows() @@ -52,7 +51,7 @@ class ListTagsCommand extends Command return [[$this->translator->translate('No tags yet')]]; } - return array_map(function (Tag $tag) { + return \array_map(function (Tag $tag) { return [$tag->getName()]; }, $tags); } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index d5473369..f76c05f2 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -9,10 +9,13 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class RenameTagCommand extends Command { + const NAME = 'tag:rename'; + /** * @var TagServiceInterface */ @@ -32,7 +35,7 @@ class RenameTagCommand extends Command protected function configure() { $this - ->setName('tag:rename') + ->setName(self::NAME) ->setDescription($this->translator->translate('Renames one existing tag.')) ->addArgument('oldName', InputArgument::REQUIRED, $this->translator->translate('Current name of the tag.')) ->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.')); @@ -40,16 +43,15 @@ class RenameTagCommand extends Command protected function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $oldName = $input->getArgument('oldName'); $newName = $input->getArgument('newName'); try { $this->tagService->renameTag($oldName, $newName); - $output->writeln(sprintf('%s', $this->translator->translate('Tag properly renamed.'))); + $io->success($this->translator->translate('Tag properly renamed.')); } catch (EntityDoesNotExistException $e) { - $output->writeln('' . sprintf($this->translator->translate( - 'A tag with name "%s" was not found' - ), $oldName) . ''); + $io->error(\sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName)); } } } diff --git a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php index 51adc356..f3e174d6 100644 --- a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php +++ b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php @@ -10,11 +10,13 @@ use Shlinkio\Shlink\Core\Service\VisitServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Zend\I18n\Translator\TranslatorInterface; class ProcessVisitsCommand extends Command { const LOCALHOST = '127.0.0.1'; + const NAME = 'visit:process'; /** * @var VisitServiceInterface @@ -42,7 +44,7 @@ class ProcessVisitsCommand extends Command public function configure() { - $this->setName('visit:process') + $this->setName(self::NAME) ->setDescription( $this->translator->translate('Processes visits where location is not set yet') ); @@ -50,13 +52,14 @@ class ProcessVisitsCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); $visits = $this->visitService->getUnlocatedVisits(); foreach ($visits as $visit) { $ipAddr = $visit->getRemoteAddr(); - $output->write(sprintf('%s %s', $this->translator->translate('Processing IP'), $ipAddr)); + $io->write(sprintf('%s %s', $this->translator->translate('Processing IP'), $ipAddr)); if ($ipAddr === self::LOCALHOST) { - $output->writeln( + $io->writeln( sprintf(' (%s)', $this->translator->translate('Ignored localhost address')) ); continue; @@ -64,11 +67,13 @@ class ProcessVisitsCommand extends Command try { $result = $this->ipLocationResolver->resolveIpLocation($ipAddr); + $location = new VisitLocation(); $location->exchangeArray($result); $visit->setVisitLocation($location); $this->visitService->saveVisit($visit); - $output->writeln(sprintf( + + $io->writeln(sprintf( ' (' . $this->translator->translate('Address located at "%s"') . ')', $location->getCityName() )); @@ -77,6 +82,6 @@ class ProcessVisitsCommand extends Command } } - $output->writeln($this->translator->translate('Finished processing all IPs')); + $io->success($this->translator->translate('Finished processing all IPs')); } } diff --git a/module/CLI/src/Factory/ApplicationFactory.php b/module/CLI/src/Factory/ApplicationFactory.php index 71278dac..21ae46fd 100644 --- a/module/CLI/src/Factory/ApplicationFactory.php +++ b/module/CLI/src/Factory/ApplicationFactory.php @@ -5,8 +5,11 @@ namespace Shlinkio\Shlink\CLI\Factory; use Interop\Container\ContainerInterface; use Interop\Container\Exception\ContainerException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Shlinkio\Shlink\Core\Options\AppOptions; use Symfony\Component\Console\Application as CliApp; +use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; use Zend\I18n\Translator\Translator; use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotFoundException; @@ -20,28 +23,23 @@ class ApplicationFactory implements FactoryInterface * @param ContainerInterface $container * @param string $requestedName * @param null|array $options - * @return object + * @return CliApp + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface * @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) + public function __invoke(ContainerInterface $container, $requestedName, array $options = null): CliApp { $config = $container->get('config')['cli']; $appOptions = $container->get(AppOptions::class); $translator = $container->get(Translator::class); $translator->setLocale($config['locale']); - $commands = isset($config['commands']) ? $config['commands'] : []; + $commands = $config['commands'] ?? []; $app = new CliApp($appOptions->getName(), $appOptions->getVersion()); - foreach ($commands as $command) { - if (! $container->has($command)) { - continue; - } - - $app->add($container->get($command)); - } + $app->setCommandLoader(new ContainerCommandLoader($container, $commands)); return $app; } diff --git a/module/CLI/src/Factory/InstallApplicationFactory.php b/module/CLI/src/Factory/InstallApplicationFactory.php index b1fc589b..274100b4 100644 --- a/module/CLI/src/Factory/InstallApplicationFactory.php +++ b/module/CLI/src/Factory/InstallApplicationFactory.php @@ -6,9 +6,8 @@ namespace Shlinkio\Shlink\CLI\Factory; use Interop\Container\ContainerInterface; use Interop\Container\Exception\ContainerException; use Shlinkio\Shlink\CLI\Command\Install\InstallCommand; -use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManager; +use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManager; use Shlinkio\Shlink\CLI\Install\Plugin; -use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory; use Symfony\Component\Console\Application; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Filesystem\Filesystem; @@ -17,6 +16,7 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Factory\FactoryInterface; +use Zend\ServiceManager\Factory\InvokableFactory; class InstallApplicationFactory implements FactoryInterface { @@ -41,11 +41,11 @@ class InstallApplicationFactory implements FactoryInterface $command = new InstallCommand( new PhpArray(), $container->get(Filesystem::class), - new ConfigCustomizerPluginManager($container, ['factories' => [ - Plugin\DatabaseConfigCustomizerPlugin::class => ConfigAbstractFactory::class, - Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, - Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, - Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + new ConfigCustomizerManager($container, ['factories' => [ + Plugin\DatabaseConfigCustomizer::class => ConfigAbstractFactory::class, + Plugin\UrlShortenerConfigCustomizer::class => InvokableFactory::class, + Plugin\LanguageConfigCustomizer::class => InvokableFactory::class, + Plugin\ApplicationConfigCustomizer::class => InvokableFactory::class, ]]), $isUpdate ); diff --git a/module/CLI/src/Install/ConfigCustomizerManager.php b/module/CLI/src/Install/ConfigCustomizerManager.php new file mode 100644 index 00000000..ee4aedfa --- /dev/null +++ b/module/CLI/src/Install/ConfigCustomizerManager.php @@ -0,0 +1,12 @@ +questionHelper = $questionHelper; - } - - /** - * @param InputInterface $input - * @param OutputInterface $output - * @param string $text - * @param string|null $default - * @param bool $allowEmpty - * @return string - * @throws RuntimeException - */ - protected function ask(InputInterface $input, OutputInterface $output, $text, $default = null, $allowEmpty = false) - { - if ($default !== null) { - $text .= ' (defaults to ' . $default . ')'; - } - do { - $value = $this->questionHelper->ask($input, $output, new Question( - '' . $text . ': ', - $default - )); - if (empty($value) && ! $allowEmpty) { - $output->writeln('Value can\'t be empty'); - } - } while (empty($value) && $default === null && ! $allowEmpty); - - return $value; - } - - /** - * @param OutputInterface $output - * @param string $text - */ - protected function printTitle(OutputInterface $output, $text) - { - $text = trim($text); - $length = strlen($text) + 4; - $header = str_repeat('*', $length); - - $output->writeln([ - '', - '' . $header . '', - '* ' . strtoupper($text) . ' *', - '' . $header . '', - ]); - } -} diff --git a/module/CLI/src/Install/Plugin/ApplicationConfigCustomizer.php b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizer.php new file mode 100644 index 00000000..70a64a90 --- /dev/null +++ b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizer.php @@ -0,0 +1,44 @@ +title('APPLICATION'); + + if ($appConfig->hasApp() && $io->confirm('Do you want to keep imported application config?')) { + return; + } + + $validator = function ($value) { + return $value; + }; + $appConfig->setApp([ + 'SECRET' => $io->ask( + 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', + null, + $validator + ) ?: $this->generateRandomString(32), + 'DISABLE_TRACK_PARAM' => $io->ask( + 'Provide a parameter name that you will be able to use to disable tracking on specific request to ' + . 'short URLs (leave empty and this feature won\'t be enabled)', + null, + $validator + ), + ]); + } +} diff --git a/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php deleted file mode 100644 index b1bf6436..00000000 --- a/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php +++ /dev/null @@ -1,43 +0,0 @@ -printTitle($output, 'APPLICATION'); - - if ($appConfig->hasApp() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion( - 'Do you want to keep imported application config? (Y/n): ' - ))) { - return; - } - - $appConfig->setApp([ - 'SECRET' => $this->ask( - $input, - $output, - 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', - null, - true - ) ?: $this->generateRandomString(32), - ]); - } -} diff --git a/module/CLI/src/Install/Plugin/ConfigCustomizerInterface.php b/module/CLI/src/Install/Plugin/ConfigCustomizerInterface.php new file mode 100644 index 00000000..0a2bf054 --- /dev/null +++ b/module/CLI/src/Install/Plugin/ConfigCustomizerInterface.php @@ -0,0 +1,17 @@ + 'pdo_mysql', + 'PostgreSQL' => 'pdo_pgsql', + 'SQLite' => 'pdo_sqlite', + ]; + + /** + * @var Filesystem + */ + private $filesystem; + + public function __construct(Filesystem $filesystem) + { + $this->filesystem = $filesystem; + } + + /** + * @param SymfonyStyle $io + * @param CustomizableAppConfig $appConfig + * @return void + * @throws IOException + */ + public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig) + { + $io->title('DATABASE'); + + if ($appConfig->hasDatabase() && $io->confirm('Do you want to keep imported database config?')) { + // If the user selected to keep DB config and is configured to use sqlite, copy DB file + if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) { + try { + $this->filesystem->copy( + $appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH, + CustomizableAppConfig::SQLITE_DB_PATH + ); + } catch (IOException $e) { + $io->error('It wasn\'t possible to import the SQLite database'); + throw $e; + } + } + + return; + } + + // Select database type + $params = []; + $databases = \array_keys(self::DATABASE_DRIVERS); + $dbType = $io->choice('Select database type', $databases, $databases[0]); + $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; + + // Ask for connection params if database is not SQLite + if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { + $params['NAME'] = $io->ask('Database name', 'shlink'); + $params['USER'] = $io->ask('Database username'); + $params['PASSWORD'] = $io->ask('Database password'); + $params['HOST'] = $io->ask('Database host', 'localhost'); + $params['PORT'] = $io->ask('Database port', $this->getDefaultDbPort($params['DRIVER'])); + } + + $appConfig->setDatabase($params); + } + + private function getDefaultDbPort(string $driver): string + { + return $driver === 'pdo_mysql' ? '3306' : '5432'; + } +} diff --git a/module/CLI/src/Install/Plugin/DatabaseConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/DatabaseConfigCustomizerPlugin.php deleted file mode 100644 index 62c81c8f..00000000 --- a/module/CLI/src/Install/Plugin/DatabaseConfigCustomizerPlugin.php +++ /dev/null @@ -1,100 +0,0 @@ - 'pdo_mysql', - 'PostgreSQL' => 'pdo_pgsql', - 'SQLite' => 'pdo_sqlite', - ]; - - /** - * @var Filesystem - */ - private $filesystem; - - /** - * DatabaseConfigCustomizerPlugin constructor. - * @param QuestionHelper $questionHelper - * @param Filesystem $filesystem - * - * @DI\Inject({QuestionHelper::class, Filesystem::class}) - */ - public function __construct(QuestionHelper $questionHelper, Filesystem $filesystem) - { - parent::__construct($questionHelper); - $this->filesystem = $filesystem; - } - - /** - * @param InputInterface $input - * @param OutputInterface $output - * @param CustomizableAppConfig $appConfig - * @return void - * @throws IOException - * @throws RuntimeException - */ - public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig) - { - $this->printTitle($output, 'DATABASE'); - - if ($appConfig->hasDatabase() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion( - 'Do you want to keep imported database config? (Y/n): ' - ))) { - // If the user selected to keep DB config and is configured to use sqlite, copy DB file - if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) { - try { - $this->filesystem->copy( - $appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH, - CustomizableAppConfig::SQLITE_DB_PATH - ); - } catch (IOException $e) { - $output->writeln('It wasn\'t possible to import the SQLite database'); - throw $e; - } - } - - return; - } - - // Select database type - $params = []; - $databases = array_keys(self::DATABASE_DRIVERS); - $dbType = $this->questionHelper->ask($input, $output, new ChoiceQuestion( - 'Select database type (defaults to ' . $databases[0] . '):', - $databases, - 0 - )); - $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; - - // Ask for connection params if database is not SQLite - if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { - $params['NAME'] = $this->ask($input, $output, 'Database name', 'shlink'); - $params['USER'] = $this->ask($input, $output, 'Database username'); - $params['PASSWORD'] = $this->ask($input, $output, 'Database password'); - $params['HOST'] = $this->ask($input, $output, 'Database host', 'localhost'); - $params['PORT'] = $this->ask($input, $output, 'Database port', $this->getDefaultDbPort($params['DRIVER'])); - } - - $appConfig->setDatabase($params); - } - - private function getDefaultDbPort($driver) - { - return $driver === 'pdo_mysql' ? '3306' : '5432'; - } -} diff --git a/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php b/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php deleted file mode 100644 index 6e1ea7a0..00000000 --- a/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php +++ /dev/null @@ -1,31 +0,0 @@ -get(QuestionHelper::class)); - } -} diff --git a/module/CLI/src/Install/Plugin/LanguageConfigCustomizer.php b/module/CLI/src/Install/Plugin/LanguageConfigCustomizer.php new file mode 100644 index 00000000..15125d32 --- /dev/null +++ b/module/CLI/src/Install/Plugin/LanguageConfigCustomizer.php @@ -0,0 +1,36 @@ +title('LANGUAGE'); + + if ($appConfig->hasLanguage() && $io->confirm('Do you want to keep imported language?')) { + return; + } + + $appConfig->setLanguage([ + 'DEFAULT' => $this->chooseLanguage('Select default language for the application in general', $io), + 'CLI' => $this->chooseLanguage('Select default language for CLI executions', $io), + ]); + } + + private function chooseLanguage(string $message, SymfonyStyle $io): string + { + return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]); + } +} diff --git a/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php deleted file mode 100644 index 83fbe7ad..00000000 --- a/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php +++ /dev/null @@ -1,49 +0,0 @@ -printTitle($output, 'LANGUAGE'); - - if ($appConfig->hasLanguage() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion( - 'Do you want to keep imported language? (Y/n): ' - ))) { - return; - } - - $appConfig->setLanguage([ - 'DEFAULT' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( - 'Select default language for the application in general (defaults to ' - . self::SUPPORTED_LANGUAGES[0] . '):', - self::SUPPORTED_LANGUAGES, - 0 - )), - 'CLI' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( - 'Select default language for CLI executions (defaults to ' - . self::SUPPORTED_LANGUAGES[0] . '):', - self::SUPPORTED_LANGUAGES, - 0 - )), - ]); - } -} diff --git a/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizer.php b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizer.php new file mode 100644 index 00000000..d010667d --- /dev/null +++ b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizer.php @@ -0,0 +1,43 @@ +title('URL SHORTENER'); + + if ($appConfig->hasUrlShortener() && $io->confirm('Do you want to keep imported URL shortener config?')) { + return; + } + + // Ask for URL shortener params + $appConfig->setUrlShortener([ + 'SCHEMA' => $io->choice( + 'Select schema for generated short URLs', + ['http', 'https'], + 'http' + ), + 'HOSTNAME' => $io->ask('Hostname for generated URLs'), + 'CHARS' => $io->ask( + 'Character set for generated short codes (leave empty to autogenerate one)', + null, + function ($value) { + return $value; + } + ) ?: \str_shuffle(UrlShortener::DEFAULT_CHARS), + 'VALIDATE_URL' => $io->confirm('Do you want to validate long urls by 200 HTTP status code on response'), + ]); + } +} diff --git a/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php deleted file mode 100644 index 3adcfc30..00000000 --- a/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php +++ /dev/null @@ -1,57 +0,0 @@ -printTitle($output, 'URL SHORTENER'); - - if ($appConfig->hasUrlShortener() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion( - 'Do you want to keep imported URL shortener config? (Y/n): ' - ))) { - return; - } - - // Ask for URL shortener params - $appConfig->setUrlShortener([ - 'SCHEMA' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( - 'Select schema for generated short URLs (defaults to http):', - ['http', 'https'], - 0 - )), - 'HOSTNAME' => $this->ask($input, $output, 'Hostname for generated URLs'), - 'CHARS' => $this->ask( - $input, - $output, - 'Character set for generated short codes (leave empty to autogenerate one)', - null, - true - ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS), - 'VALIDATE_URL' => $this->questionHelper->ask( - $input, - $output, - new ConfirmationQuestion( - 'Do you want to validate long urls by 200 HTTP status code on response (Y/n):' - ) - ), - ]); - } -} diff --git a/module/CLI/src/Model/CustomizableAppConfig.php b/module/CLI/src/Model/CustomizableAppConfig.php index 5784f753..b228baa8 100644 --- a/module/CLI/src/Model/CustomizableAppConfig.php +++ b/module/CLI/src/Model/CustomizableAppConfig.php @@ -225,6 +225,7 @@ final class CustomizableAppConfig implements ArraySerializableInterface $config = [ 'app_options' => [ 'secret_key' => $this->app['SECRET'], + 'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'], ], 'entity_manager' => [ 'connection' => [ diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index f577f298..e51ee7a8 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -59,6 +59,6 @@ class DisableKeyCommandTest extends TestCase 'apiKey' => $apiKey, ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals('API key "abcd1234" does not exist.' . PHP_EOL, $output); + $this->assertContains('API key "abcd1234" does not exist.', $output); } } diff --git a/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php b/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php index 5e11684b..a9d62c18 100644 --- a/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php +++ b/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php @@ -5,7 +5,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\Config; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand; -use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; use Zend\I18n\Translator\Translator; @@ -32,7 +31,6 @@ class GenerateCharsetCommandTest extends TestCase public function charactersAreGeneratedFromDefault() { $prefix = 'Character set: '; - $prefixLength = strlen($prefix); $this->commandTester->execute([ 'command' => 'config:generate-charset', @@ -40,13 +38,7 @@ class GenerateCharsetCommandTest extends TestCase $output = $this->commandTester->getDisplay(); // Both default character set and the new one should have the same length - $this->assertEquals($prefixLength + strlen(UrlShortener::DEFAULT_CHARS) + 1, strlen($output)); - - // Both default character set and the new one should have the same characters - $charset = substr($output, $prefixLength, strlen(UrlShortener::DEFAULT_CHARS)); - $orderedDefault = $this->orderStringLetters(UrlShortener::DEFAULT_CHARS); - $orderedCharset = $this->orderStringLetters($charset); - $this->assertEquals($orderedDefault, $orderedCharset); + $this->assertContains($prefix, $output); } protected function orderStringLetters($string) diff --git a/module/CLI/test/Command/Install/InstallCommandTest.php b/module/CLI/test/Command/Install/InstallCommandTest.php index afe83383..a4c49f9e 100644 --- a/module/CLI/test/Command/Install/InstallCommandTest.php +++ b/module/CLI/test/Command/Install/InstallCommandTest.php @@ -8,8 +8,8 @@ use Prophecy\Argument; use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Install\InstallCommand; -use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface; -use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerPluginInterface; +use Shlinkio\Shlink\CLI\Install\ConfigCustomizerManagerInterface; +use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Helper\ProcessHelper; use Symfony\Component\Console\Tester\CommandTester; @@ -51,8 +51,8 @@ class InstallCommandTest extends TestCase $this->configWriter = $this->prophesize(WriterInterface::class); - $configCustomizer = $this->prophesize(ConfigCustomizerPluginInterface::class); - $configCustomizers = $this->prophesize(ConfigCustomizerPluginManagerInterface::class); + $configCustomizer = $this->prophesize(ConfigCustomizerInterface::class); + $configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class); $configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal()); $app = new Application(); diff --git a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php index e1dc5229..5b83cc4f 100644 --- a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php @@ -65,8 +65,9 @@ class GenerateShortcodeCommandTest extends TestCase 'longUrl' => 'http://domain.com/invalid', ]); $output = $this->commandTester->getDisplay(); - $this->assertTrue( - strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0 + $this->assertContains( + 'Provided URL "http://domain.com/invalid" is invalid.', + $output ); } } diff --git a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php index a18cf217..8aede93a 100644 --- a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php +++ b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php @@ -10,7 +10,6 @@ use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; use Zend\I18n\Translator\Translator; use Zend\Paginator\Adapter\ArrayAdapter; @@ -22,10 +21,6 @@ class ListShortcodesCommandTest extends TestCase * @var CommandTester */ protected $commandTester; - /** - * @var QuestionHelper - */ - protected $questionHelper; /** * @var ObjectProphecy */ @@ -37,8 +32,6 @@ class ListShortcodesCommandTest extends TestCase $app = new Application(); $command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([])); $app->add($command); - - $this->questionHelper = $command->getHelper('question'); $this->commandTester = new CommandTester($command); } @@ -47,10 +40,10 @@ class ListShortcodesCommandTest extends TestCase */ public function noInputCallsListJustOnce() { - $this->questionHelper->setInputStream($this->getInputStream('\n')); $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) ->shouldBeCalledTimes(1); + $this->commandTester->setInputs(['n']); $this->commandTester->execute(['command' => 'shortcode:list']); } @@ -61,22 +54,15 @@ class ListShortcodesCommandTest extends TestCase { // The paginator will return more than one page for the first 3 times $data = []; - for ($i = 0; $i < 30; $i++) { + for ($i = 0; $i < 50; $i++) { $data[] = new ShortUrl(); } - $data = array_chunk($data, 11); - $questionHelper = $this->questionHelper; - $that = $this; - $this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use ( - &$data, - $questionHelper, - $that - ) { - $questionHelper->setInputStream($that->getInputStream('y')); - return new Paginator(new ArrayAdapter(array_shift($data))); + $this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) { + return new Paginator(new ArrayAdapter($data)); })->shouldBeCalledTimes(3); + $this->commandTester->setInputs(['y', 'y', 'n']); $this->commandTester->execute(['command' => 'shortcode:list']); } @@ -91,10 +77,10 @@ class ListShortcodesCommandTest extends TestCase $data[] = new ShortUrl(); } - $this->questionHelper->setInputStream($this->getInputStream('n')); $this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data))) ->shouldBeCalledTimes(1); + $this->commandTester->setInputs(['n']); $this->commandTester->execute(['command' => 'shortcode:list']); } @@ -104,10 +90,10 @@ class ListShortcodesCommandTest extends TestCase public function passingPageWillMakeListStartOnThatPage() { $page = 5; - $this->questionHelper->setInputStream($this->getInputStream('\n')); $this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) ->shouldBeCalledTimes(1); + $this->commandTester->setInputs(['y']); $this->commandTester->execute([ 'command' => 'shortcode:list', '--page' => $page, @@ -119,24 +105,15 @@ class ListShortcodesCommandTest extends TestCase */ public function ifTagsFlagIsProvidedTagsColumnIsIncluded() { - $this->questionHelper->setInputStream($this->getInputStream('\n')); $this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter())) ->shouldBeCalledTimes(1); + $this->commandTester->setInputs(['y']); $this->commandTester->execute([ 'command' => 'shortcode:list', '--showTags' => true, ]); $output = $this->commandTester->getDisplay(); - $this->assertTrue(strpos($output, 'Tags') > 0); - } - - protected function getInputStream($inputData) - { - $stream = fopen('php://memory', 'r+', false); - fputs($stream, $inputData); - rewind($stream); - - return $stream; + $this->assertContains('Tags', $output); } } diff --git a/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php b/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php index da0eadc3..d1bc50a3 100644 --- a/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php @@ -66,7 +66,7 @@ class ResolveUrlCommandTest extends TestCase 'shortCode' => $shortCode, ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals('Provided short code "' . $shortCode . '" could not be found.' . PHP_EOL, $output); + $this->assertContains('Provided short code "' . $shortCode . '" could not be found.', $output); } /** @@ -83,6 +83,6 @@ class ResolveUrlCommandTest extends TestCase 'shortCode' => $shortCode, ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals('Provided short code "' . $shortCode . '" has an invalid format.' . PHP_EOL, $output); + $this->assertContains('Provided short code "' . $shortCode . '" has an invalid format.', $output); } } diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index 6dab8d86..a81a2383 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -14,10 +14,6 @@ use Zend\I18n\Translator\Translator; class CreateTagCommandTest extends TestCase { - /** - * @var CreateTagCommand - */ - private $command; /** * @var CommandTester */ @@ -63,7 +59,7 @@ class CreateTagCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertContains(sprintf('Created tags: ["%s"]', implode('", "', $tagNames)), $output); + $this->assertContains('Tags properly created', $output); $createTags->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index b8f58ff7..819342c9 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -64,7 +64,7 @@ class DeleteTagsCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertContains(sprintf('Deleted tags: ["%s"]', implode('", "', $tagNames)), $output); + $this->assertContains('Tags properly deleted', $output); $deleteTags->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php deleted file mode 100644 index 675dade7..00000000 --- a/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php +++ /dev/null @@ -1,95 +0,0 @@ -questionHelper = $this->prophesize(QuestionHelper::class); - $this->plugin = new ApplicationConfigCustomizerPlugin($this->questionHelper->reveal()); - } - - /** - * @test - */ - public function configIsRequestedToTheUser() - { - /** @var MethodProphecy $askSecret */ - $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('the_secret'); - $config = new CustomizableAppConfig(); - - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); - - $this->assertTrue($config->hasApp()); - $this->assertEquals([ - 'SECRET' => 'the_secret', - ], $config->getApp()); - $askSecret->shouldHaveBeenCalledTimes(1); - } - - /** - * @test - */ - public function overwriteIsRequestedIfValueIsAlreadySet() - { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { - $last = array_pop($args); - return $last instanceof ConfirmationQuestion ? false : 'the_new_secret'; - }); - $config = new CustomizableAppConfig(); - $config->setApp([ - 'SECRET' => 'foo', - ]); - - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); - - $this->assertEquals([ - 'SECRET' => 'the_new_secret', - ], $config->getApp()); - $ask->shouldHaveBeenCalledTimes(2); - } - - /** - * @test - */ - public function existingValueIsKeptIfRequested() - { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); - - $config = new CustomizableAppConfig(); - $config->setApp([ - 'SECRET' => 'foo', - ]); - - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); - - $this->assertEquals([ - 'SECRET' => 'foo', - ], $config->getApp()); - $ask->shouldHaveBeenCalledTimes(1); - } -} diff --git a/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerTest.php b/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerTest.php new file mode 100644 index 00000000..5450d0b1 --- /dev/null +++ b/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerTest.php @@ -0,0 +1,91 @@ +io = $this->prophesize(SymfonyStyle::class); + $this->io->title(Argument::any())->willReturn(null); + + $this->plugin = new ApplicationConfigCustomizer(); + } + + /** + * @test + */ + public function configIsRequestedToTheUser() + { + $ask = $this->io->ask(Argument::cetera())->willReturn('the_secret'); + $config = new CustomizableAppConfig(); + + $this->plugin->process($this->io->reveal(), $config); + + $this->assertTrue($config->hasApp()); + $this->assertEquals([ + 'SECRET' => 'the_secret', + 'DISABLE_TRACK_PARAM' => 'the_secret', + ], $config->getApp()); + $ask->shouldHaveBeenCalledTimes(2); + } + + /** + * @test + */ + public function overwriteIsRequestedIfValueIsAlreadySet() + { + $ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret'); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(false); + $config = new CustomizableAppConfig(); + $config->setApp([ + 'SECRET' => 'foo', + ]); + + $this->plugin->process($this->io->reveal(), $config); + + $this->assertEquals([ + 'SECRET' => 'the_new_secret', + 'DISABLE_TRACK_PARAM' => 'the_new_secret', + ], $config->getApp()); + $ask->shouldHaveBeenCalledTimes(2); + $confirm->shouldHaveBeenCalledTimes(1); + } + + /** + * @test + */ + public function existingValueIsKeptIfRequested() + { + $confirm = $this->io->confirm(Argument::cetera())->willReturn(true); + + $config = new CustomizableAppConfig(); + $config->setApp([ + 'SECRET' => 'foo', + ]); + + $this->plugin->process($this->io->reveal(), $config); + + $this->assertEquals([ + 'SECRET' => 'foo', + ], $config->getApp()); + $confirm->shouldHaveBeenCalledTimes(1); + } +} diff --git a/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerTest.php similarity index 56% rename from module/CLI/test/Install/Plugin/DatabaseConfigCustomizerPluginTest.php rename to module/CLI/test/Install/Plugin/DatabaseConfigCustomizerTest.php index bfaf2491..17835666 100644 --- a/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerPluginTest.php +++ b/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerTest.php @@ -5,26 +5,22 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizerPlugin; +use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\NullOutput; -use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Filesystem\Filesystem; -class DatabaseConfigCustomizerPluginTest extends TestCase +class DatabaseConfigCustomizerTest extends TestCase { /** - * @var DatabaseConfigCustomizerPlugin + * @var DatabaseConfigCustomizer */ private $plugin; /** * @var ObjectProphecy */ - private $questionHelper; + private $io; /** * @var ObjectProphecy */ @@ -32,13 +28,11 @@ class DatabaseConfigCustomizerPluginTest extends TestCase public function setUp() { - $this->questionHelper = $this->prophesize(QuestionHelper::class); + $this->io = $this->prophesize(SymfonyStyle::class); + $this->io->title(Argument::any())->willReturn(null); $this->filesystem = $this->prophesize(Filesystem::class); - $this->plugin = new DatabaseConfigCustomizerPlugin( - $this->questionHelper->reveal(), - $this->filesystem->reveal() - ); + $this->plugin = new DatabaseConfigCustomizer($this->filesystem->reveal()); } /** @@ -46,22 +40,23 @@ class DatabaseConfigCustomizerPluginTest extends TestCase */ public function configIsRequestedToTheUser() { - /** @var MethodProphecy $askSecret */ - $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('MySQL'); + $choice = $this->io->choice(Argument::cetera())->willReturn('MySQL'); + $ask = $this->io->ask(Argument::cetera())->willReturn('param'); $config = new CustomizableAppConfig(); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertTrue($config->hasDatabase()); $this->assertEquals([ 'DRIVER' => 'pdo_mysql', - 'NAME' => 'MySQL', - 'USER' => 'MySQL', - 'PASSWORD' => 'MySQL', - 'HOST' => 'MySQL', - 'PORT' => 'MySQL', + 'NAME' => 'param', + 'USER' => 'param', + 'PASSWORD' => 'param', + 'HOST' => 'param', + 'PORT' => 'param', ], $config->getDatabase()); - $askSecret->shouldHaveBeenCalledTimes(6); + $choice->shouldHaveBeenCalledTimes(1); + $ask->shouldHaveBeenCalledTimes(5); } /** @@ -69,11 +64,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase */ public function overwriteIsRequestedIfValueIsAlreadySet() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { - $last = array_pop($args); - return $last instanceof ConfirmationQuestion ? false : 'MySQL'; - }); + $choice = $this->io->choice(Argument::cetera())->willReturn('MySQL'); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(false); + $ask = $this->io->ask(Argument::cetera())->willReturn('MySQL'); $config = new CustomizableAppConfig(); $config->setDatabase([ 'DRIVER' => 'pdo_pgsql', @@ -84,7 +77,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase 'PORT' => 'MySQL', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'DRIVER' => 'pdo_mysql', @@ -94,7 +87,9 @@ class DatabaseConfigCustomizerPluginTest extends TestCase 'HOST' => 'MySQL', 'PORT' => 'MySQL', ], $config->getDatabase()); - $ask->shouldHaveBeenCalledTimes(7); + $confirm->shouldHaveBeenCalledTimes(1); + $choice->shouldHaveBeenCalledTimes(1); + $ask->shouldHaveBeenCalledTimes(5); } /** @@ -102,8 +97,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase */ public function existingValueIsKeptIfRequested() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(true); $config = new CustomizableAppConfig(); $config->setDatabase([ @@ -115,7 +109,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase 'PORT' => 'MySQL', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'DRIVER' => 'pdo_pgsql', @@ -125,7 +119,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase 'HOST' => 'MySQL', 'PORT' => 'MySQL', ], $config->getDatabase()); - $ask->shouldHaveBeenCalledTimes(1); + $confirm->shouldHaveBeenCalledTimes(1); } /** @@ -133,9 +127,7 @@ class DatabaseConfigCustomizerPluginTest extends TestCase */ public function sqliteDatabaseIsImportedWhenRequested() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); - /** @var MethodProphecy $copy */ + $confirm = $this->io->confirm(Argument::cetera())->willReturn(true); $copy = $this->filesystem->copy(Argument::cetera())->willReturn(null); $config = new CustomizableAppConfig(); @@ -143,12 +135,12 @@ class DatabaseConfigCustomizerPluginTest extends TestCase 'DRIVER' => 'pdo_sqlite', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'DRIVER' => 'pdo_sqlite', ], $config->getDatabase()); - $ask->shouldHaveBeenCalledTimes(1); + $confirm->shouldHaveBeenCalledTimes(1); $copy->shouldHaveBeenCalledTimes(1); } } diff --git a/module/CLI/test/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactoryTest.php b/module/CLI/test/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactoryTest.php deleted file mode 100644 index 509099ed..00000000 --- a/module/CLI/test/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactoryTest.php +++ /dev/null @@ -1,40 +0,0 @@ -factory = new DefaultConfigCustomizerPluginFactory(); - } - - /** - * @test - */ - public function createsProperService() - { - $instance = $this->factory->__invoke(new ServiceManager(['services' => [ - QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(), - ]]), ApplicationConfigCustomizerPlugin::class); - $this->assertInstanceOf(ApplicationConfigCustomizerPlugin::class, $instance); - - $instance = $this->factory->__invoke(new ServiceManager(['services' => [ - QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(), - ]]), LanguageConfigCustomizerPlugin::class); - $this->assertInstanceOf(LanguageConfigCustomizerPlugin::class, $instance); - } -} diff --git a/module/CLI/test/Install/Plugin/LanguageConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/LanguageConfigCustomizerTest.php similarity index 50% rename from module/CLI/test/Install/Plugin/LanguageConfigCustomizerPluginTest.php rename to module/CLI/test/Install/Plugin/LanguageConfigCustomizerTest.php index 0edc382e..cd7d57d4 100644 --- a/module/CLI/test/Install/Plugin/LanguageConfigCustomizerPluginTest.php +++ b/module/CLI/test/Install/Plugin/LanguageConfigCustomizerTest.php @@ -5,30 +5,27 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizerPlugin; +use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizer; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\NullOutput; -use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; -class LanguageConfigCustomizerPluginTest extends TestCase +class LanguageConfigCustomizerTest extends TestCase { /** - * @var LanguageConfigCustomizerPlugin + * @var LanguageConfigCustomizer */ protected $plugin; /** * @var ObjectProphecy */ - protected $questionHelper; + protected $io; public function setUp() { - $this->questionHelper = $this->prophesize(QuestionHelper::class); - $this->plugin = new LanguageConfigCustomizerPlugin($this->questionHelper->reveal()); + $this->io = $this->prophesize(SymfonyStyle::class); + $this->io->title(Argument::any())->willReturn(null); + $this->plugin = new LanguageConfigCustomizer(); } /** @@ -36,18 +33,17 @@ class LanguageConfigCustomizerPluginTest extends TestCase */ public function configIsRequestedToTheUser() { - /** @var MethodProphecy $askSecret */ - $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('en'); + $ask = $this->io->choice(Argument::cetera())->willReturn('en'); $config = new CustomizableAppConfig(); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertTrue($config->hasLanguage()); $this->assertEquals([ 'DEFAULT' => 'en', 'CLI' => 'en', ], $config->getLanguage()); - $askSecret->shouldHaveBeenCalledTimes(2); + $ask->shouldHaveBeenCalledTimes(2); } /** @@ -55,24 +51,22 @@ class LanguageConfigCustomizerPluginTest extends TestCase */ public function overwriteIsRequestedIfValueIsAlreadySet() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { - $last = array_pop($args); - return $last instanceof ConfirmationQuestion ? false : 'es'; - }); + $choice = $this->io->choice(Argument::cetera())->willReturn('es'); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(false); $config = new CustomizableAppConfig(); $config->setLanguage([ 'DEFAULT' => 'en', 'CLI' => 'en', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'DEFAULT' => 'es', 'CLI' => 'es', ], $config->getLanguage()); - $ask->shouldHaveBeenCalledTimes(3); + $choice->shouldHaveBeenCalledTimes(2); + $confirm->shouldHaveBeenCalledTimes(1); } /** @@ -80,8 +74,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase */ public function existingValueIsKeptIfRequested() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + $ask = $this->io->confirm(Argument::cetera())->willReturn(true); $config = new CustomizableAppConfig(); $config->setLanguage([ @@ -89,7 +82,7 @@ class LanguageConfigCustomizerPluginTest extends TestCase 'CLI' => 'es', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'DEFAULT' => 'es', diff --git a/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerTest.php similarity index 54% rename from module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php rename to module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerTest.php index 2c396db2..1b6c6a0f 100644 --- a/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php +++ b/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerTest.php @@ -5,30 +5,27 @@ namespace ShlinkioTest\Shlink\CLI\Install\Plugin; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizerPlugin; +use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizer; use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\NullOutput; -use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; -class UrlShortenerConfigCustomizerPluginTest extends TestCase +class UrlShortenerConfigCustomizerTest extends TestCase { /** - * @var UrlShortenerConfigCustomizerPlugin + * @var UrlShortenerConfigCustomizer */ private $plugin; /** * @var ObjectProphecy */ - private $questionHelper; + private $io; public function setUp() { - $this->questionHelper = $this->prophesize(QuestionHelper::class); - $this->plugin = new UrlShortenerConfigCustomizerPlugin($this->questionHelper->reveal()); + $this->io = $this->prophesize(SymfonyStyle::class); + $this->io->title(Argument::any())->willReturn(null); + $this->plugin = new UrlShortenerConfigCustomizer(); } /** @@ -36,20 +33,23 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase */ public function configIsRequestedToTheUser() { - /** @var MethodProphecy $askSecret */ - $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('something'); + $choice = $this->io->choice(Argument::cetera())->willReturn('something'); + $ask = $this->io->ask(Argument::cetera())->willReturn('something'); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(true); $config = new CustomizableAppConfig(); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertTrue($config->hasUrlShortener()); $this->assertEquals([ 'SCHEMA' => 'something', 'HOSTNAME' => 'something', 'CHARS' => 'something', - 'VALIDATE_URL' => 'something', + 'VALIDATE_URL' => true, ], $config->getUrlShortener()); - $askSecret->shouldHaveBeenCalledTimes(4); + $ask->shouldHaveBeenCalledTimes(2); + $choice->shouldHaveBeenCalledTimes(1); + $confirm->shouldHaveBeenCalledTimes(1); } /** @@ -57,20 +57,18 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase */ public function overwriteIsRequestedIfValueIsAlreadySet() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) { - $last = array_pop($args); - return $last instanceof ConfirmationQuestion ? false : 'foo'; - }); + $choice = $this->io->choice(Argument::cetera())->willReturn('foo'); + $ask = $this->io->ask(Argument::cetera())->willReturn('foo'); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(false); $config = new CustomizableAppConfig(); $config->setUrlShortener([ 'SCHEMA' => 'bar', 'HOSTNAME' => 'bar', 'CHARS' => 'bar', - 'VALIDATE_URL' => 'bar', + 'VALIDATE_URL' => true, ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'SCHEMA' => 'foo', @@ -78,7 +76,9 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase 'CHARS' => 'foo', 'VALIDATE_URL' => false, ], $config->getUrlShortener()); - $ask->shouldHaveBeenCalledTimes(5); + $ask->shouldHaveBeenCalledTimes(2); + $choice->shouldHaveBeenCalledTimes(1); + $confirm->shouldHaveBeenCalledTimes(2); } /** @@ -86,8 +86,7 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase */ public function existingValueIsKeptIfRequested() { - /** @var MethodProphecy $ask */ - $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + $confirm = $this->io->confirm(Argument::cetera())->willReturn(true); $config = new CustomizableAppConfig(); $config->setUrlShortener([ @@ -97,7 +96,7 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase 'VALIDATE_URL' => 'foo', ]); - $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + $this->plugin->process($this->io->reveal(), $config); $this->assertEquals([ 'SCHEMA' => 'foo', @@ -105,6 +104,6 @@ class UrlShortenerConfigCustomizerPluginTest extends TestCase 'CHARS' => 'foo', 'VALIDATE_URL' => 'foo', ], $config->getUrlShortener()); - $ask->shouldHaveBeenCalledTimes(1); + $confirm->shouldHaveBeenCalledTimes(1); } } diff --git a/module/Common/src/Exception/ExceptionInterface.php b/module/Common/src/Exception/ExceptionInterface.php index 72e53d0f..349f3f4b 100644 --- a/module/Common/src/Exception/ExceptionInterface.php +++ b/module/Common/src/Exception/ExceptionInterface.php @@ -3,6 +3,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Common\Exception; -interface ExceptionInterface +interface ExceptionInterface extends \Throwable { } diff --git a/module/Common/src/Exception/WrongIpException.php b/module/Common/src/Exception/WrongIpException.php index 0e70d4e2..837f9ec7 100644 --- a/module/Common/src/Exception/WrongIpException.php +++ b/module/Common/src/Exception/WrongIpException.php @@ -5,7 +5,7 @@ namespace Shlinkio\Shlink\Common\Exception; class WrongIpException extends RuntimeException { - public static function fromIpAddress($ipAddress, \Exception $prev = null) + public static function fromIpAddress($ipAddress, \Throwable $prev = null) { return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev); } diff --git a/module/Common/src/Service/IpLocationResolver.php b/module/Common/src/Service/IpLocationResolver.php index 218d2288..017bf89c 100644 --- a/module/Common/src/Service/IpLocationResolver.php +++ b/module/Common/src/Service/IpLocationResolver.php @@ -22,10 +22,11 @@ class IpLocationResolver implements IpLocationResolverInterface } /** - * @param $ipAddress + * @param string $ipAddress * @return array + * @throws WrongIpException */ - public function resolveIpLocation($ipAddress) + public function resolveIpLocation(string $ipAddress): array { try { $response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress)); diff --git a/module/Common/src/Service/IpLocationResolverInterface.php b/module/Common/src/Service/IpLocationResolverInterface.php index 4f4279a2..fc1f07a3 100644 --- a/module/Common/src/Service/IpLocationResolverInterface.php +++ b/module/Common/src/Service/IpLocationResolverInterface.php @@ -3,11 +3,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Common\Service; +use Shlinkio\Shlink\Common\Exception\WrongIpException; + interface IpLocationResolverInterface { /** - * @param $ipAddress + * @param string $ipAddress * @return array + * @throws WrongIpException */ - public function resolveIpLocation($ipAddress); + public function resolveIpLocation(string $ipAddress): array; } diff --git a/module/Common/src/Util/DateRange.php b/module/Common/src/Util/DateRange.php index 215de520..f58f2033 100644 --- a/module/Common/src/Util/DateRange.php +++ b/module/Common/src/Util/DateRange.php @@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\Common\Util; class DateRange { /** - * @var \DateTimeInterface + * @var \DateTimeInterface|null */ private $startDate; /** - * @var \DateTimeInterface + * @var \DateTimeInterface|null */ private $endDate; @@ -21,7 +21,7 @@ class DateRange } /** - * @return \DateTimeInterface + * @return \DateTimeInterface|null */ public function getStartDate() { @@ -29,7 +29,7 @@ class DateRange } /** - * @return \DateTimeInterface + * @return \DateTimeInterface|null */ public function getEndDate() { @@ -39,8 +39,8 @@ class DateRange /** * @return bool */ - public function isEmpty() + public function isEmpty(): bool { - return is_null($this->startDate) && is_null($this->endDate); + return $this->startDate === null && $this->endDate === null; } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 31a4619a..11038e4b 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -55,7 +55,11 @@ return [ Service\Tag\TagService::class => ['em'], // Middleware - Action\RedirectAction::class => [Service\UrlShortener::class, Service\VisitsTracker::class], + Action\RedirectAction::class => [ + Service\UrlShortener::class, + Service\VisitsTracker::class, + Options\AppOptions::class, + ], Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'], Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class], Middleware\QrCodeCacheMiddleware::class => [Cache::class], diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index e22639c7..e7702dde 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Zend\Diactoros\Response\RedirectResponse; @@ -26,11 +27,19 @@ class RedirectAction implements MiddlewareInterface * @var VisitsTrackerInterface */ private $visitTracker; + /** + * @var AppOptions + */ + private $appOptions; - public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker) - { + public function __construct( + UrlShortenerInterface $urlShortener, + VisitsTrackerInterface $visitTracker, + AppOptions $appOptions + ) { $this->urlShortener = $urlShortener; $this->visitTracker = $visitTracker; + $this->appOptions = $appOptions; } /** @@ -45,18 +54,16 @@ class RedirectAction implements MiddlewareInterface public function process(Request $request, DelegateInterface $delegate) { $shortCode = $request->getAttribute('shortCode', ''); + $query = $request->getQueryParams(); + $disableTrackParam = $this->appOptions->getDisableTrackParam(); try { $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); - // If provided shortCode does not belong to a valid long URL, dispatch next middleware, which will trigger - // a not-found error - if ($longUrl === null) { - return $delegate->process($request); - } - // Track visit to this short code - $this->visitTracker->track($shortCode, $request); + if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) { + $this->visitTracker->track($shortCode, $request); + } // Return a redirect response to the long URL. // Use a temporary redirect to make sure browsers always hit the server for analytics purposes diff --git a/module/Core/src/Exception/EntityDoesNotExistException.php b/module/Core/src/Exception/EntityDoesNotExistException.php index 732eaeba..ba4d233d 100644 --- a/module/Core/src/Exception/EntityDoesNotExistException.php +++ b/module/Core/src/Exception/EntityDoesNotExistException.php @@ -3,9 +3,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; -use Shlinkio\Shlink\Common\Exception\ExceptionInterface; - -class EntityDoesNotExistException extends \RuntimeException implements ExceptionInterface +class EntityDoesNotExistException extends RuntimeException { public static function createFromEntityAndConditions($entityName, array $conditions) { diff --git a/module/Core/src/Exception/ExceptionInterface.php b/module/Core/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..2901b7cb --- /dev/null +++ b/module/Core/src/Exception/ExceptionInterface.php @@ -0,0 +1,8 @@ +getCode() : -1; - return new static(sprintf('Provided URL "%s" is not an exisitng and valid URL', $url), $code, $previous); + return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous); } } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 4f76b725..d94a61b5 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -3,8 +3,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Exception; -use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; - class NonUniqueSlugException extends InvalidArgumentException { public static function fromSlug(string $slug): self diff --git a/module/Core/src/Exception/RuntimeException.php b/module/Core/src/Exception/RuntimeException.php new file mode 100644 index 00000000..70ee4cc5 --- /dev/null +++ b/module/Core/src/Exception/RuntimeException.php @@ -0,0 +1,8 @@ +invalidElements = $invalidElements; + parent::__construct($message, $code, $previous); + } + + /** + * @param InputFilterInterface $inputFilter + * @param \Throwable|null $prev + * @return ValidationException + */ + public static function fromInputFilter(InputFilterInterface $inputFilter, \Throwable $prev = null): self + { + return static::fromArray($inputFilter->getMessages(), $prev); + } + + /** + * @param array $invalidData + * @param \Throwable|null $prev + * @return ValidationException + */ + public static function fromArray(array $invalidData, \Throwable $prev = null): self + { + return new self( + \sprintf( + 'Provided data is not valid. These are the messages:%s%s%s', + PHP_EOL, + self::formMessagesToString($invalidData), + PHP_EOL + ), + $invalidData, + -1, + $prev + ); + } + + private static function formMessagesToString(array $messages = []) + { + $text = ''; + foreach ($messages as $name => $messageSet) { + $text .= \sprintf( + "\n\t'%s' => %s", + $name, + \is_array($messageSet) ? \print_r($messageSet, true) : $messageSet + ); + } + + return $text; + } + + /** + * @return array + */ + public function getInvalidElements(): array + { + return $this->invalidElements; + } +} diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php new file mode 100644 index 00000000..f79edce6 --- /dev/null +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -0,0 +1,141 @@ +validate($data); + return $instance; + } + + /** + * @param string|\DateTimeInterface|null $validSince + * @param string|\DateTimeInterface|null $validUntil + * @param string|null $customSlug + * @param int|null $maxVisits + * @return ShortUrlMeta + * @throws ValidationException + */ + public static function createFromParams( + $validSince = null, + $validUntil = null, + $customSlug = null, + $maxVisits = null + ): self { + // We do not type hint the arguments because that will be done by the validation process + $instance = new self(); + $instance->validate([ + ShortUrlMetaInputFilter::VALID_SINCE => $validSince, + ShortUrlMetaInputFilter::VALID_UNTIL => $validUntil, + ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug, + ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits, + ]); + return $instance; + } + + /** + * @param array $data + * @throws ValidationException + */ + private function validate(array $data) + { + $inputFilter = new ShortUrlMetaInputFilter($data); + if (! $inputFilter->isValid()) { + 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->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); + $this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS); + $this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null; + } + + /** + * @return \DateTime|null + */ + public function getValidSince() + { + return $this->validSince; + } + + public function hasValidSince(): bool + { + return $this->validSince !== null; + } + + /** + * @return \DateTime|null + */ + public function getValidUntil() + { + return $this->validUntil; + } + + public function hasValidUntil(): bool + { + return $this->validUntil !== null; + } + + /** + * @return null|string + */ + public function getCustomSlug() + { + return $this->customSlug; + } + + public function hasCustomSlug(): bool + { + return $this->customSlug !== null; + } + + /** + * @return int|null + */ + public function getMaxVisits() + { + return $this->maxVisits; + } + + public function hasMaxVisits(): bool + { + return $this->maxVisits !== null; + } +} diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index edb46f08..0220dbd8 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -22,6 +22,10 @@ class AppOptions extends AbstractOptions * @var string */ protected $secretKey = ''; + /** + * @var string|null + */ + protected $disableTrackParam; /** * AppOptions constructor. @@ -86,6 +90,24 @@ class AppOptions extends AbstractOptions return $this; } + /** + * @return string|null + */ + public function getDisableTrackParam() + { + return $this->disableTrackParam; + } + + /** + * @param string|null $disableTrackParam + * @return $this|self + */ + protected function setDisableTrackParam($disableTrackParam): self + { + $this->disableTrackParam = $disableTrackParam; + return $this; + } + /** * @return string */ diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 2e22717b..3a89b828 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -3,10 +3,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Zend\Paginator\Paginator; @@ -16,11 +17,11 @@ class ShortUrlService implements ShortUrlServiceInterface use TagManagerTrait; /** - * @var EntityManagerInterface + * @var ORM\EntityManagerInterface */ private $em; - public function __construct(EntityManagerInterface $em) + public function __construct(ORM\EntityManagerInterface $em) { $this->em = $em; } @@ -49,9 +50,48 @@ class ShortUrlService implements ShortUrlServiceInterface * @return ShortUrl * @throws InvalidShortCodeException */ - public function setTagsByShortCode($shortCode, array $tags = []) + public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl { - /** @var ShortUrl $shortUrl */ + $shortUrl = $this->findByShortCode($shortCode); + $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); + $this->em->flush(); + + return $shortUrl; + } + + /** + * @param string $shortCode + * @param ShortUrlMeta $shortCodeMeta + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl + { + $shortUrl = $this->findByShortCode($shortCode); + if ($shortCodeMeta->hasValidSince()) { + $shortUrl->setValidSince($shortCodeMeta->getValidSince()); + } + if ($shortCodeMeta->hasValidUntil()) { + $shortUrl->setValidUntil($shortCodeMeta->getValidUntil()); + } + if ($shortCodeMeta->hasMaxVisits()) { + $shortUrl->setMaxVisits($shortCodeMeta->getMaxVisits()); + } + + /** @var ORM\EntityManager $em */ + $em = $this->em; + $em->flush($shortUrl); + return $shortUrl; + } + + /** + * @param string $shortCode + * @return ShortUrl + * @throws InvalidShortCodeException + */ + private function findByShortCode(string $shortCode): ShortUrl + { + /** @var ShortUrl|null $shortUrl */ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, ]); @@ -59,9 +99,6 @@ class ShortUrlService implements ShortUrlServiceInterface throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); } - $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); - $this->em->flush(); - return $shortUrl; } } diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index e299842d..2350105b 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Zend\Paginator\Paginator; interface ShortUrlServiceInterface @@ -24,5 +25,13 @@ interface ShortUrlServiceInterface * @return ShortUrl * @throws InvalidShortCodeException */ - public function setTagsByShortCode($shortCode, array $tags = []); + public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl; + + /** + * @param string $shortCode + * @param ShortUrlMeta $shortCodeMeta + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl; } diff --git a/module/Core/src/Service/Tag/TagService.php b/module/Core/src/Service/Tag/TagService.php index b9427eee..65ae733a 100644 --- a/module/Core/src/Service/Tag/TagService.php +++ b/module/Core/src/Service/Tag/TagService.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service\Tag; use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Repository\TagRepository; @@ -15,11 +15,11 @@ class TagService implements TagServiceInterface use TagManagerTrait; /** - * @var EntityManagerInterface + * @var ORM\EntityManagerInterface */ private $em; - public function __construct(EntityManagerInterface $em) + public function __construct(ORM\EntityManagerInterface $em) { $this->em = $em; } @@ -63,6 +63,7 @@ class TagService implements TagServiceInterface * @param string $newName * @return Tag * @throws EntityDoesNotExistException + * @throws ORM\OptimisticLockException */ public function renameTag($oldName, $newName) { @@ -74,7 +75,10 @@ class TagService implements TagServiceInterface } $tag->setName($newName); - $this->em->flush($tag); + + /** @var ORM\EntityManager $em */ + $em = $this->em; + $em->flush($tag); return $tag; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index cd282035..87621060 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -7,16 +7,15 @@ use Cocur\Slugify\Slugify; use Cocur\Slugify\SlugifyInterface; use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\ORMException; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use Psr\Http\Message\UriInterface; -use Shlinkio\Shlink\Common\Exception\RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; +use Shlinkio\Shlink\Core\Exception\RuntimeException; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Util\TagManagerTrait; @@ -90,6 +89,7 @@ class UrlShortener implements UrlShortenerInterface int $maxVisits = null ): string { // If the url already exists in the database, just return its short code + /** @var ShortUrl|null $shortUrl */ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'originalUrl' => $url, ]); @@ -118,14 +118,14 @@ class UrlShortener implements UrlShortenerInterface $this->em->flush(); // Generate the short code and persist it - $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode($shortUrl->getId()); + $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId()); $shortUrl->setShortCode($shortCode) ->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); $this->em->commit(); return $shortCode; - } catch (ORMException $e) { + } catch (\Throwable $e) { if ($this->em->getConnection()->isTransactionActive()) { $this->em->rollback(); $this->em->close(); @@ -155,13 +155,13 @@ class UrlShortener implements UrlShortenerInterface /** * Generates the unique shortcode for an autoincrement ID * - * @param int $id + * @param float $id * @return string */ - private function convertAutoincrementIdToShortCode($id): string + private function convertAutoincrementIdToShortCode(float $id): string { - $id = ((int) $id) + 200000; // Increment the Id so that the generated shortcode is not too short - $length = strlen($this->chars); + $id += 200000; // Increment the Id so that the generated shortcode is not too short + $length = \strlen($this->chars); $code = ''; while ($id > 0) { diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 17859a81..9dd354d5 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Psr\Http\Message\UriInterface; -use Shlinkio\Shlink\Common\Exception\RuntimeException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; +use Shlinkio\Shlink\Core\Exception\RuntimeException; interface UrlShortenerInterface { diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index 38e44fea..2103c04a 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -3,23 +3,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Repository\VisitRepository; class VisitsTracker implements VisitsTrackerInterface { /** - * @var EntityManagerInterface|EntityManager + * @var ORM\EntityManagerInterface */ private $em; - public function __construct(EntityManagerInterface $em) + public function __construct(ORM\EntityManagerInterface $em) { $this->em = $em; } @@ -29,6 +28,8 @@ class VisitsTracker implements VisitsTrackerInterface * * @param string $shortCode * @param ServerRequestInterface $request + * @throws ORM\ORMInvalidArgumentException + * @throws ORM\OptimisticLockException */ public function track($shortCode, ServerRequestInterface $request) { @@ -43,8 +44,10 @@ class VisitsTracker implements VisitsTrackerInterface ->setReferer($request->getHeaderLine('Referer')) ->setRemoteAddr($this->findOutRemoteAddr($request)); - $this->em->persist($visit); - $this->em->flush($visit); + /** @var ORM\EntityManager $em */ + $em = $this->em; + $em->persist($visit); + $em->flush($visit); } /** @@ -66,14 +69,14 @@ class VisitsTracker implements VisitsTrackerInterface /** * Returns the visits on certain short code * - * @param $shortCode + * @param string $shortCode * @param DateRange $dateRange * @return Visit[] * @throws InvalidArgumentException */ - public function info($shortCode, DateRange $dateRange = null): array + public function info(string $shortCode, DateRange $dateRange = null): array { - /** @var ShortUrl $shortUrl */ + /** @var ShortUrl|null $shortUrl */ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, ]); diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index d3295f37..802983b0 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; interface VisitsTrackerInterface { @@ -21,10 +21,10 @@ interface VisitsTrackerInterface /** * Returns the visits on certain short code * - * @param $shortCode + * @param string $shortCode * @param DateRange $dateRange * @return Visit[] * @throws InvalidArgumentException */ - public function info($shortCode, DateRange $dateRange = null): array; + public function info(string $shortCode, DateRange $dateRange = null): array; } diff --git a/module/Core/src/Validation/InputFactoryTrait.php b/module/Core/src/Validation/InputFactoryTrait.php new file mode 100644 index 00000000..289c54e2 --- /dev/null +++ b/module/Core/src/Validation/InputFactoryTrait.php @@ -0,0 +1,20 @@ +setRequired($required) + ->getFilterChain()->attach(new StripTags()) + ->attach(new StringTrim()); + return $input; + } +} diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php new file mode 100644 index 00000000..435ef441 --- /dev/null +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -0,0 +1,45 @@ +initialize(); + if ($data !== null) { + $this->setData($data); + } + } + + private function initialize() + { + $validSince = $this->createInput(self::VALID_SINCE, false); + $validSince->getValidatorChain()->attach(new Date(['format' => \DateTime::ATOM])); + $this->add($validSince); + + $validUntil = $this->createInput(self::VALID_UNTIL, false); + $validUntil->getValidatorChain()->attach(new Date(['format' => \DateTime::ATOM])); + $this->add($validUntil); + + $this->add($this->createInput(self::CUSTOM_SLUG, false)); + + $maxVisits = $this->createInput(self::MAX_VISITS, false); + $maxVisits->getValidatorChain()->attach(new IsInt()) + ->attach(new GreaterThan(['min' => 1, 'inclusive' => true])); + $this->add($maxVisits); + } +} diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index a429549f..ed37baae 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -10,6 +10,7 @@ use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; +use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\VisitsTracker; use ShlinkioTest\Shlink\Common\Util\TestUtils; @@ -26,13 +27,21 @@ class RedirectActionTest extends TestCase * @var ObjectProphecy */ protected $urlShortener; + /** + * @var ObjectProphecy + */ + protected $visitTracker; public function setUp() { $this->urlShortener = $this->prophesize(UrlShortener::class); - $visitTracker = $this->prophesize(VisitsTracker::class); - $visitTracker->track(Argument::any()); - $this->action = new RedirectAction($this->urlShortener->reveal(), $visitTracker->reveal()); + $this->visitTracker = $this->prophesize(VisitsTracker::class); + + $this->action = new RedirectAction( + $this->urlShortener->reveal(), + $this->visitTracker->reveal(), + new AppOptions(['disableTrackParam' => 'foobar']) + ); } /** @@ -44,6 +53,8 @@ class RedirectActionTest extends TestCase $expectedUrl = 'http://domain.com/foo/bar'; $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl) ->shouldBeCalledTimes(1); + $this->visitTracker->track(Argument::cetera())->willReturn(null) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, TestUtils::createDelegateMock()->reveal()); @@ -62,6 +73,9 @@ class RedirectActionTest extends TestCase $shortCode = 'abc123'; $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) ->shouldBeCalledTimes(1); + $this->visitTracker->track(Argument::cetera())->willReturn(null) + ->shouldNotBeCalled(); + $delegate = $this->prophesize(DelegateInterface::class); /** @var MethodProphecy $process */ $process = $delegate->process(Argument::any())->willReturn(new Response()); @@ -71,4 +85,26 @@ class RedirectActionTest extends TestCase $process->shouldHaveBeenCalledTimes(1); } + + /** + * @test + */ + public function visitIsNotTrackedIfDisableParamIsProvided() + { + $shortCode = 'abc123'; + $expectedUrl = 'http://domain.com/foo/bar'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl) + ->shouldBeCalledTimes(1); + $this->visitTracker->track(Argument::cetera())->willReturn(null) + ->shouldNotBeCalled(); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode) + ->withQueryParams(['foobar' => true]); + $response = $this->action->process($request, TestUtils::createDelegateMock()->reveal()); + + $this->assertInstanceOf(Response\RedirectResponse::class, $response); + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertEquals($expectedUrl, $response->getHeaderLine('Location')); + } } diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php new file mode 100644 index 00000000..8341fbb7 --- /dev/null +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -0,0 +1,59 @@ +expectException(ValidationException::class); + ShortUrlMeta::createFromRawData($data); + } + + public function provideInvalidData(): array + { + return [ + [[ + ShortUrlMetaInputFilter::VALID_SINCE => '', + ShortUrlMetaInputFilter::VALID_UNTIL => '', + ShortUrlMetaInputFilter::CUSTOM_SLUG => 'foobar', + ShortUrlMetaInputFilter::MAX_VISITS => 'invalid', + ]], + [[ + ShortUrlMetaInputFilter::VALID_SINCE => '2017', + ShortUrlMetaInputFilter::MAX_VISITS => 5, + ]], + ]; + } + + /** + * @test + */ + public function properlyCreatedInstanceReturnsValues() + { + $meta = ShortUrlMeta::createFromParams((new \DateTime('2015-01-01'))->format(\DateTime::ATOM), null, 'foobar'); + + $this->assertTrue($meta->hasValidSince()); + $this->assertEquals(new \DateTime('2015-01-01'), $meta->getValidSince()); + + $this->assertFalse($meta->hasValidUntil()); + $this->assertNull($meta->getValidUntil()); + + $this->assertTrue($meta->hasCustomSlug()); + $this->assertEquals('foobar', $meta->getCustomSlug()); + + $this->assertFalse($meta->hasMaxVisits()); + $this->assertNull($meta->getMaxVisits()); + } +} diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index ed98a5bc..cc406713 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -10,6 +10,8 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrlService; @@ -55,7 +57,6 @@ class ShortUrlServiceTest extends TestCase /** * @test - * @expectedException \Shlinkio\Shlink\Core\Exception\InvalidShortCodeException */ public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode() { @@ -65,6 +66,7 @@ class ShortUrlServiceTest extends TestCase ->shouldBeCalledTimes(1); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->expectException(InvalidShortCodeException::class); $this->service->setTagsByShortCode($shortCode); } @@ -88,4 +90,32 @@ class ShortUrlServiceTest extends TestCase $this->service->setTagsByShortCode($shortCode, ['foo', 'bar']); } + + /** + * @test + */ + public function updateMetadataByShortCodeUpdatesProvidedData() + { + $shortUrl = new ShortUrl(); + + $repo = $this->prophesize(ShortUrlRepository::class); + $findShortUrl = $repo->findOneBy(['shortCode' => 'abc123'])->willReturn($shortUrl); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $flush = $this->em->flush($shortUrl)->willReturn(null); + + $result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::createFromParams( + (new \DateTime('2017-01-01 00:00:00'))->format(\DateTime::ATOM), + (new \DateTime('2017-01-05 00:00:00'))->format(\DateTime::ATOM), + null, + 5 + )); + + $this->assertSame($shortUrl, $result); + $this->assertEquals(new \DateTime('2017-01-01 00:00:00'), $shortUrl->getValidSince()); + $this->assertEquals(new \DateTime('2017-01-05 00:00:00'), $shortUrl->getValidUntil()); + $this->assertEquals(5, $shortUrl->getMaxVisits()); + $findShortUrl->shouldHaveBeenCalled(); + $getRepo->shouldHaveBeenCalled(); + $flush->shouldHaveBeenCalled(); + } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 5b32c49d..6f78c70c 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -99,7 +99,7 @@ class UrlShortenerTest extends TestCase /** * @test - * @expectedException \Shlinkio\Shlink\Common\Exception\RuntimeException + * @expectedException \Shlinkio\Shlink\Core\Exception\RuntimeException */ public function exceptionIsThrownWhenOrmThrowsException() { diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 760f46c8..bfd1a774 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -21,6 +21,7 @@ return [ Action\AuthenticateAction::class => ConfigAbstractFactory::class, Action\CreateShortcodeAction::class => ConfigAbstractFactory::class, + Action\EditShortCodeAction::class => ConfigAbstractFactory::class, Action\ResolveUrlAction::class => ConfigAbstractFactory::class, Action\GetVisitsAction::class => ConfigAbstractFactory::class, Action\ListShortcodesAction::class => ConfigAbstractFactory::class, @@ -48,6 +49,7 @@ return [ 'config.url_shortener.domain', 'Logger_Shlink', ], + Action\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',], Action\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'], Action\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'], Action\ListShortcodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 742f904b..a1bd8dca 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -9,7 +9,7 @@ return [ 'routes' => [ [ 'name' => Action\AuthenticateAction::class, - 'path' => '/rest/v{version:1}/authenticate', + 'path' => '/authenticate', 'middleware' => Action\AuthenticateAction::class, 'allowed_methods' => [RequestMethod::METHOD_POST], ], @@ -17,25 +17,31 @@ return [ // Short codes [ 'name' => Action\CreateShortcodeAction::class, - 'path' => '/rest/v{version:1}/short-codes', + 'path' => '/short-codes', 'middleware' => Action\CreateShortcodeAction::class, 'allowed_methods' => [RequestMethod::METHOD_POST], ], + [ + 'name' => Action\EditShortCodeAction::class, + 'path' => '/short-codes/{shortCode}', + 'middleware' => Action\EditShortCodeAction::class, + 'allowed_methods' => [RequestMethod::METHOD_PUT], + ], [ 'name' => Action\ResolveUrlAction::class, - 'path' => '/rest/v{version:1}/short-codes/{shortCode}', + 'path' => '/short-codes/{shortCode}', 'middleware' => Action\ResolveUrlAction::class, 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ 'name' => Action\ListShortcodesAction::class, - 'path' => '/rest/v{version:1}/short-codes', + 'path' => '/short-codes', 'middleware' => Action\ListShortcodesAction::class, 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ 'name' => Action\EditShortcodeTagsAction::class, - 'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags', + 'path' => '/short-codes/{shortCode}/tags', 'middleware' => Action\EditShortcodeTagsAction::class, 'allowed_methods' => [RequestMethod::METHOD_PUT], ], @@ -43,7 +49,7 @@ return [ // Visits [ 'name' => Action\GetVisitsAction::class, - 'path' => '/rest/v{version:1}/short-codes/{shortCode}/visits', + 'path' => '/short-codes/{shortCode}/visits', 'middleware' => Action\GetVisitsAction::class, 'allowed_methods' => [RequestMethod::METHOD_GET], ], @@ -51,25 +57,25 @@ return [ // Tags [ 'name' => Action\Tag\ListTagsAction::class, - 'path' => '/rest/v{version:1}/tags', + 'path' => '/tags', 'middleware' => Action\Tag\ListTagsAction::class, 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ 'name' => Action\Tag\DeleteTagsAction::class, - 'path' => '/rest/v{version:1}/tags', + 'path' => '/tags', 'middleware' => Action\Tag\DeleteTagsAction::class, 'allowed_methods' => [RequestMethod::METHOD_DELETE], ], [ 'name' => Action\Tag\CreateTagsAction::class, - 'path' => '/rest/v{version:1}/tags', + 'path' => '/tags', 'middleware' => Action\Tag\CreateTagsAction::class, 'allowed_methods' => [RequestMethod::METHOD_POST], ], [ 'name' => Action\Tag\UpdateTagAction::class, - 'path' => '/rest/v{version:1}/tags', + 'path' => '/tags', 'middleware' => Action\Tag\UpdateTagAction::class, 'allowed_methods' => [RequestMethod::METHOD_PUT], ], diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index f81f6971..89c5cdbd 100644 Binary files a/module/Rest/lang/es.mo and b/module/Rest/lang/es.mo differ diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po index fa02fd1f..566bb99e 100644 --- a/module/Rest/lang/es.po +++ b/module/Rest/lang/es.po @@ -1,15 +1,15 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2017-10-21 20:20+0200\n" -"PO-Revision-Date: 2017-10-21 20:20+0200\n" +"POT-Creation-Date: 2018-01-21 09:40+0100\n" +"PO-Revision-Date: 2018-01-21 09:40+0100\n" "Last-Translator: Alejandro Celaya \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.0.4\n" "X-Poedit-Basepath: ..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -39,13 +39,16 @@ msgstr "El slug proporcionado \"%s\" ya está en uso. Prueba con uno diferente." msgid "Unexpected error occurred" msgstr "Ocurrió un error inesperado" -msgid "A list of tags was not provided" -msgstr "No se ha proporcionado una lista de etiquetas" - #, php-format msgid "No URL found for short code \"%s\"" msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" +msgid "Provided data is invalid." +msgstr "Los datos proporcionados son inválidos." + +msgid "A list of tags was not provided" +msgstr "No se ha proporcionado una lista de etiquetas" + #, php-format msgid "Provided short code %s does not exist" msgstr "El código corto \"%s\" proporcionado no existe" diff --git a/module/Rest/src/Action/CreateShortcodeAction.php b/module/Rest/src/Action/CreateShortcodeAction.php index 8edcd499..56169592 100644 --- a/module/Rest/src/Action/CreateShortcodeAction.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -50,7 +50,7 @@ class CreateShortcodeAction extends AbstractRestAction */ public function process(Request $request, DelegateInterface $delegate) { - $postData = $request->getParsedBody(); + $postData = (array) $request->getParsedBody(); if (! isset($postData['longUrl'])) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, diff --git a/module/Rest/src/Action/EditShortCodeAction.php b/module/Rest/src/Action/EditShortCodeAction.php new file mode 100644 index 00000000..a8f12de6 --- /dev/null +++ b/module/Rest/src/Action/EditShortCodeAction.php @@ -0,0 +1,74 @@ +shortUrlService = $shortUrlService; + $this->translator = $translator; + } + + /** + * Process an incoming server request and return a response, optionally delegating + * to the next middleware component to create the response. + * + * @param ServerRequestInterface $request + * @param DelegateInterface $delegate + * + * @return ResponseInterface + * @throws \InvalidArgumentException + */ + public function process(ServerRequestInterface $request, DelegateInterface $delegate): ResponseInterface + { + $postData = (array) $request->getParsedBody(); + $shortCode = $request->getAttribute('shortCode', ''); + + try { + $this->shortUrlService->updateMetadataByShortCode( + $shortCode, + ShortUrlMeta::createFromRawData($postData) + ); + return new EmptyResponse(); + } catch (Exception\InvalidShortCodeException $e) { + $this->logger->warning('Provided data is invalid.' . PHP_EOL . $e); + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => \sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), + ], self::STATUS_NOT_FOUND); + } catch (Exception\ValidationException $e) { + $this->logger->warning('Provided data is invalid.' . PHP_EOL . $e); + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => $this->translator->translate('Provided data is invalid.'), + ], self::STATUS_BAD_REQUEST); + } + } +} diff --git a/module/Rest/src/Action/GetVisitsAction.php b/module/Rest/src/Action/GetVisitsAction.php index 0d03acf1..490b0470 100644 --- a/module/Rest/src/Action/GetVisitsAction.php +++ b/module/Rest/src/Action/GetVisitsAction.php @@ -75,10 +75,10 @@ class GetVisitsAction extends AbstractRestAction /** * @param Request $request - * @param $key + * @param string $key * @return \DateTime|null */ - protected function getDateQueryParam(Request $request, $key) + private function getDateQueryParam(Request $request, string $key) { $query = $request->getQueryParams(); if (! isset($query[$key]) || empty($query[$key])) { diff --git a/module/Rest/src/Authentication/JWTService.php b/module/Rest/src/Authentication/JWTService.php index 74130438..6a0d3ac8 100644 --- a/module/Rest/src/Authentication/JWTService.php +++ b/module/Rest/src/Authentication/JWTService.php @@ -27,7 +27,7 @@ class JWTService implements JWTServiceInterface * @param int $lifetime * @return string */ - public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME) + public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME): string { $currentTimestamp = time(); @@ -48,7 +48,7 @@ class JWTService implements JWTServiceInterface * @return string * @throws AuthenticationException If the token has expired */ - public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME) + public function refresh(string $jwt, $lifetime = self::DEFAULT_LIFETIME): string { $payload = $this->getPayload($jwt); $payload['exp'] = time() + $lifetime; @@ -61,7 +61,7 @@ class JWTService implements JWTServiceInterface * @param string $jwt * @return bool */ - public function verify($jwt) + public function verify(string $jwt): bool { try { // If no exception is thrown while decoding the token, it is considered valid @@ -79,7 +79,7 @@ class JWTService implements JWTServiceInterface * @return array * @throws AuthenticationException If the token has expired */ - public function getPayload($jwt) + public function getPayload(string $jwt): array { try { return $this->decode($jwt); @@ -92,16 +92,16 @@ class JWTService implements JWTServiceInterface * @param array $data * @return string */ - protected function encode(array $data) + protected function encode(array $data): string { return JWT::encode($data, $this->appOptions->getSecretKey(), self::DEFAULT_ENCRYPTION_ALG); } /** - * @param $jwt + * @param string $jwt * @return array */ - protected function decode($jwt) + protected function decode(string $jwt): array { return (array) JWT::decode($jwt, $this->appOptions->getSecretKey(), [self::DEFAULT_ENCRYPTION_ALG]); } diff --git a/module/Rest/src/Authentication/JWTServiceInterface.php b/module/Rest/src/Authentication/JWTServiceInterface.php index 55c8fb36..ba162aa8 100644 --- a/module/Rest/src/Authentication/JWTServiceInterface.php +++ b/module/Rest/src/Authentication/JWTServiceInterface.php @@ -18,7 +18,7 @@ interface JWTServiceInterface * @param int $lifetime * @return string */ - public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME); + public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME): string; /** * Refreshes a token and returns it with the new expiration @@ -28,7 +28,7 @@ interface JWTServiceInterface * @return string * @throws AuthenticationException If the token has expired */ - public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME); + public function refresh(string $jwt, $lifetime = self::DEFAULT_LIFETIME): string; /** * Verifies that certain JWT is valid @@ -36,7 +36,7 @@ interface JWTServiceInterface * @param string $jwt * @return bool */ - public function verify($jwt); + public function verify(string $jwt): bool; /** * Decodes certain token and returns the payload @@ -45,5 +45,5 @@ interface JWTServiceInterface * @return array * @throws AuthenticationException If the token has expired */ - public function getPayload($jwt); + public function getPayload(string $jwt): array; } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 3fc7a9be..c6afdcfe 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -8,8 +8,24 @@ use Zend\Stdlib\Glob; class ConfigProvider { + const ROUTES_PREFIX = '/rest/v{version:1}'; + public function __invoke() { - return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE)); + /** @var array $config */ + $config = Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE)); + return $this->applyRoutesPrefix($config); + } + + private function applyRoutesPrefix(array $config): array + { + $routes =& $config['routes'] ?? []; + + // Prepend the routes prefix to every path + foreach ($routes as $key => $route) { + $routes[$key]['path'] = self::ROUTES_PREFIX . $route['path']; + } + + return $config; } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 4ef72f92..284cc302 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -25,7 +25,7 @@ class ApiKey extends AbstractEntity */ protected $key; /** - * @var \DateTime + * @var \DateTime|null * @ORM\Column(name="expiration_date", nullable=true, type="datetime") */ protected $expirationDate; @@ -60,7 +60,7 @@ class ApiKey extends AbstractEntity } /** - * @return \DateTime + * @return \DateTime|null */ public function getExpirationDate() { diff --git a/module/Rest/src/Exception/AuthenticationException.php b/module/Rest/src/Exception/AuthenticationException.php index 36c151f3..63805483 100644 --- a/module/Rest/src/Exception/AuthenticationException.php +++ b/module/Rest/src/Exception/AuthenticationException.php @@ -3,9 +3,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Exception; -use Shlinkio\Shlink\Common\Exception\ExceptionInterface; - -class AuthenticationException extends \RuntimeException implements ExceptionInterface +class AuthenticationException extends RuntimeException { public static function fromCredentials($username, $password) { diff --git a/module/Rest/src/Exception/ExceptionInterface.php b/module/Rest/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..45df3e09 --- /dev/null +++ b/module/Rest/src/Exception/ExceptionInterface.php @@ -0,0 +1,8 @@ +getParsedBody(); // In requests that do not allow body or if the body has already been parsed, continue to next middleware - if (! empty($currentParams) || in_array($method, [ + if (! empty($currentParams) || \in_array($method, [ self::METHOD_GET, self::METHOD_HEAD, self::METHOD_OPTIONS, @@ -37,7 +37,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac // If the accepted content is JSON, try to parse the body from JSON $contentType = $this->getRequestContentType($request); - if (in_array($contentType, ['application/json', 'text/json', 'application/x-json'], true)) { + if (\in_array($contentType, ['application/json', 'text/json', 'application/x-json'], true)) { return $delegate->process($this->parseFromJson($request)); } @@ -48,27 +48,28 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac * @param Request $request * @return string */ - protected function getRequestContentType(Request $request) + private function getRequestContentType(Request $request): string { $contentType = $request->getHeaderLine('Content-type'); - $contentTypes = explode(';', $contentType); - return trim(array_shift($contentTypes)); + $contentTypes = \explode(';', $contentType); + return \trim(\array_shift($contentTypes)); } /** * @param Request $request * @return Request + * @throws RuntimeException */ - protected function parseFromJson(Request $request) + private function parseFromJson(Request $request): Request { $rawBody = (string) $request->getBody(); if (empty($rawBody)) { return $request; } - $parsedJson = json_decode($rawBody, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException(sprintf('Error when parsing JSON request body: %s', json_last_error_msg())); + $parsedJson = \json_decode($rawBody, true); + if (\json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException(\sprintf('Error when parsing JSON request body: %s', \json_last_error_msg())); } return $request->withParsedBody($parsedJson); @@ -78,7 +79,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac * @param Request $request * @return Request */ - protected function parseFromUrlEncoded(Request $request) + private function parseFromUrlEncoded(Request $request): Request { $rawBody = (string) $request->getBody(); if (empty($rawBody)) { diff --git a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php index b5823f4f..071eadba 100644 --- a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php @@ -55,13 +55,14 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface, StatusCodeIn * * @return Response * @throws \InvalidArgumentException + * @throws \ErrorException */ public function process(Request $request, DelegateInterface $delegate) { // If current route is the authenticate route or an OPTIONS request, continue to the next middleware - /** @var RouteResult $routeResult */ + /** @var RouteResult|null $routeResult */ $routeResult = $request->getAttribute(RouteResult::class); - if (! isset($routeResult) + if ($routeResult === null || $routeResult->isFailure() || $routeResult->getMatchedRouteName() === AuthenticateAction::class || $request->getMethod() === 'OPTIONS' diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index e2473f1b..6f368e56 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -46,13 +46,9 @@ class ApiKeyService implements ApiKeyServiceInterface */ public function check($key) { - /** @var ApiKey $apiKey */ + /** @var ApiKey|null $apiKey */ $apiKey = $this->getByKey($key); - if (! isset($apiKey)) { - return false; - } - - return $apiKey->isValid(); + return $apiKey !== null && $apiKey->isValid(); } /** @@ -60,12 +56,13 @@ class ApiKeyService implements ApiKeyServiceInterface * * @param string $key * @return ApiKey + * @throws InvalidArgumentException */ public function disable($key) { - /** @var ApiKey $apiKey */ + /** @var ApiKey|null $apiKey */ $apiKey = $this->getByKey($key); - if (! isset($apiKey)) { + if ($apiKey === null) { throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key)); } @@ -75,7 +72,7 @@ class ApiKeyService implements ApiKeyServiceInterface } /** - * Lists all existing appi keys + * Lists all existing api keys * * @param bool $enabledOnly Tells if only enabled keys should be returned * @return ApiKey[] @@ -94,8 +91,10 @@ class ApiKeyService implements ApiKeyServiceInterface */ public function getByKey($key) { - return $this->em->getRepository(ApiKey::class)->findOneBy([ + /** @var ApiKey|null $apiKey */ + $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ 'key' => $key, ]); + return $apiKey; } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 51dadf7a..ceaa3b96 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Service; +use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ApiKeyServiceInterface @@ -28,11 +29,12 @@ interface ApiKeyServiceInterface * * @param string $key * @return ApiKey + * @throws InvalidArgumentException */ public function disable($key); /** - * Lists all existing appi keys + * Lists all existing api keys * * @param bool $enabledOnly Tells if only enabled keys should be returned * @return ApiKey[] diff --git a/module/Rest/src/Util/RestUtils.php b/module/Rest/src/Util/RestUtils.php index dcd6632e..b9d96891 100644 --- a/module/Rest/src/Util/RestUtils.php +++ b/module/Rest/src/Util/RestUtils.php @@ -20,7 +20,7 @@ class RestUtils const NOT_FOUND_ERROR = 'NOT_FOUND'; const UNKNOWN_ERROR = 'UNKNOWN_ERROR'; - public static function getRestErrorCodeFromException(Common\ExceptionInterface $e) + public static function getRestErrorCodeFromException(\Throwable $e) { switch (true) { case $e instanceof Core\InvalidShortCodeException: @@ -30,6 +30,7 @@ class RestUtils case $e instanceof Core\NonUniqueSlugException: return self::INVALID_SLUG_ERROR; case $e instanceof Common\InvalidArgumentException: + case $e instanceof Core\ValidationException: return self::INVALID_ARGUMENT_ERROR; case $e instanceof Rest\AuthenticationException: return self::INVALID_CREDENTIALS_ERROR; diff --git a/module/Rest/test/Action/AuthenticateActionTest.php b/module/Rest/test/Action/AuthenticateActionTest.php index 32079e9b..4fb2518f 100644 --- a/module/Rest/test/Action/AuthenticateActionTest.php +++ b/module/Rest/test/Action/AuthenticateActionTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Rest\Action\AuthenticateAction; use Shlinkio\Shlink\Rest\Authentication\JWTService; @@ -32,6 +33,8 @@ class AuthenticateActionTest extends TestCase { $this->apiKeyService = $this->prophesize(ApiKeyService::class); $this->jwtService = $this->prophesize(JWTService::class); + $this->jwtService->create(Argument::cetera())->willReturn(''); + $this->action = new AuthenticateAction( $this->apiKeyService->reveal(), $this->jwtService->reveal(), diff --git a/module/Rest/test/Action/EditShortCodeActionTest.php b/module/Rest/test/Action/EditShortCodeActionTest.php new file mode 100644 index 00000000..2690811a --- /dev/null +++ b/module/Rest/test/Action/EditShortCodeActionTest.php @@ -0,0 +1,93 @@ +shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); + $this->action = new EditShortCodeAction($this->shortUrlService->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function invalidDataReturnsError() + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'maxVisits' => 'invalid', + ]); + + /** @var JsonResponse $resp */ + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + $payload = $resp->getPayload(); + + $this->assertEquals(400, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_ARGUMENT_ERROR, $payload['error']); + $this->assertEquals('Provided data is invalid.', $payload['message']); + } + + /** + * @test + */ + public function incorrectShortCodeReturnsError() + { + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody([ + 'maxVisits' => 5, + ]); + $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willThrow( + InvalidShortCodeException::class + ); + + /** @var JsonResponse $resp */ + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + $payload = $resp->getPayload(); + + $this->assertEquals(404, $resp->getStatusCode()); + $this->assertEquals(RestUtils::INVALID_SHORTCODE_ERROR, $payload['error']); + $this->assertEquals('No URL found for short code "abc123"', $payload['message']); + $updateMeta->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function correctShortCodeReturnsSuccess() + { + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody([ + 'maxVisits' => 5, + ]); + $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willReturn(new ShortUrl()); + + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(204, $resp->getStatusCode()); + $updateMeta->shouldHaveBeenCalled(); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..77e32e1e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + excludes_analyse: + - module/Common/src/Template/Extension/TranslatorExtension.php diff --git a/phpunit-func.xml b/phpunit-func.xml index d125e506..f06c11ee 100644 --- a/phpunit-func.xml +++ b/phpunit-func.xml @@ -1,4 +1,9 @@ - + + ./module/*/test-func diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ddd4f42b..af71c7cd 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,4 +1,9 @@ - + + ./module/Common/test