diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index 1a5e12de..5dc306bc 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -1,7 +1,8 @@ [ '' == '@', ], + ServiceManager::build('') => [ + '' == '@', + ], ]; diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fca031..7aa00a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## CHANGELOG +### 1.5.0 + +**Enhancements:** + +* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95) +* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59) +* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66) + +**Tasks** + +* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96) +* [76: Add response examples to swagger docs](https://github.com/shlinkio/shlink/issues/76) +* [93: Improve cross domain management by using the ImplicitOptionsMiddleware](https://github.com/shlinkio/shlink/issues/93) + +**Bugs** + +* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92) + ### 1.4.0 **Enhancements:** diff --git a/bin/cli b/bin/cli index 66086b23..263df59e 100755 --- a/bin/cli +++ b/bin/cli @@ -5,7 +5,4 @@ use Symfony\Component\Console\Application as CliApp; /** @var ContainerInterface $container */ $container = include __DIR__ . '/../config/container.php'; - -/** @var CliApp $app */ -$app = $container->get(CliApp::class); -$app->run(); +$container->get(CliApp::class)->run(); diff --git a/bin/install b/bin/install index c47036a9..43a07cd3 100755 --- a/bin/install +++ b/bin/install @@ -1,14 +1,19 @@ #!/usr/bin/env php add(new InstallCommand(new PhpArray())); -$app->setDefaultCommand('shlink:install'); -$app->run(); +$container = new ServiceManager(['factories' => [ + Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, +]]); +$container->build(Application::class)->run(); diff --git a/bin/update b/bin/update index b226a9fa..164e20b0 100755 --- a/bin/update +++ b/bin/update @@ -1,14 +1,19 @@ #!/usr/bin/env php add(new UpdateCommand(new PhpArray())); -$app->setDefaultCommand('shlink:install'); -$app->run(); +$container = new ServiceManager(['factories' => [ + Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, +]]); +$container->build(Application::class, ['isUpdate' => true])->run(); diff --git a/build.sh b/build.sh index ae952aae..b3c65a53 100755 --- a/build.sh +++ b/build.sh @@ -8,7 +8,7 @@ if [ "$#" -ne 1 ]; then fi version=$1 -builtcontent=$(readlink -f '../shlink_build_tmp') +builtcontent=$(readlink -f "../shlink_${version}_dist") projectdir=$(pwd) # Copy project content to temp dir @@ -31,6 +31,8 @@ rm build.sh rm CHANGELOG.md rm composer.* rm LICENSE +rm indocker +rm docker-compose.yml rm php* rm README.md rm -r build @@ -42,5 +44,5 @@ rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore} # Compressing file rm -f "${projectdir}"/build/shlink_${version}_dist.zip -zip -ry "${projectdir}"/build/shlink_${version}_dist.zip . +zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist" rm -rf "${builtcontent}" diff --git a/composer.json b/composer.json index ec6e6ce9..637a66ab 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "roave/security-advisories": "dev-master", "filp/whoops": "^2.0", "symfony/var-dumper": "^3.0", - "vlucas/phpdotenv": "^2.2" + "vlucas/phpdotenv": "^2.2", + "zendframework/zend-expressive-tooling": "^0.4" }, "autoload": { "psr-4": { diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index e3f8fbdd..fd7147a1 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -1,10 +1,12 @@ [ 'name' => 'Shlink', 'version' => '1.2.0', - 'secret_key' => env('SECRET_KEY'), + 'secret_key' => Common\env('SECRET_KEY'), ], ]; diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index e2b88ba8..83a893dc 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -1,6 +1,8 @@ Twig\TwigEnvironmentFactory::class, Router\RouterInterface::class => Router\FastRouteRouterFactory::class, ErrorHandler::class => Container\ErrorHandlerFactory::class, + Middleware\ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class, ], ], diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 6bdcae77..803eb8ff 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -1,4 +1,6 @@ [ @@ -6,9 +8,9 @@ return [ 'proxies_dir' => 'data/proxies', ], 'connection' => [ - 'user' => env('DB_USER'), - 'password' => env('DB_PASSWORD'), - 'dbname' => env('DB_NAME', 'shlink'), + 'user' => Common\env('DB_USER'), + 'password' => Common\env('DB_PASSWORD'), + 'dbname' => Common\env('DB_NAME', 'shlink'), 'charset' => 'utf8', ], ], diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index e07149a5..bf81f13e 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -4,7 +4,7 @@ use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware; use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware; use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware; use Shlinkio\Shlink\Rest\Middleware\PathVersionMiddleware; -use Zend\Expressive\Container\ApplicationFactory; +use Zend\Expressive; use Zend\Stratigility\Middleware\ErrorHandler; return [ @@ -27,7 +27,7 @@ return [ 'routing' => [ 'middleware' => [ - ApplicationFactory::ROUTING_MIDDLEWARE, + Expressive\Application::ROUTING_MIDDLEWARE, ], 'priority' => 10, ], @@ -36,6 +36,7 @@ return [ 'path' => '/rest', 'middleware' => [ CrossDomainMiddleware::class, + Expressive\Middleware\ImplicitOptionsMiddleware::class, BodyParserMiddleware::class, CheckAuthenticationMiddleware::class, ], @@ -44,7 +45,7 @@ return [ 'post-routing' => [ 'middleware' => [ - ApplicationFactory::DISPATCH_MIDDLEWARE, + Expressive\Application::DISPATCH_MIDDLEWARE, ], 'priority' => 1, ], diff --git a/config/autoload/translator.global.php b/config/autoload/translator.global.php index d0561cbe..2ce6bb44 100644 --- a/config/autoload/translator.global.php +++ b/config/autoload/translator.global.php @@ -1,8 +1,10 @@ [ - 'locale' => env('DEFAULT_LOCALE', 'en'), + 'locale' => Common\env('DEFAULT_LOCALE', 'en'), ], ]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 5b0d95c9..f17d192d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -1,14 +1,15 @@ [ 'domain' => [ - 'schema' => env('SHORTENED_URL_SCHEMA', 'http'), - 'hostname' => env('SHORTENED_URL_HOSTNAME'), + 'schema' => Common\env('SHORTENED_URL_SCHEMA', 'http'), + 'hostname' => Common\env('SHORTENED_URL_HOSTNAME'), ], - 'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS), + 'shortcode_chars' => Common\env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS), ], ]; diff --git a/docs/swagger/paths/v1_authenticate.json b/docs/swagger/paths/v1_authenticate.json index 843081bb..5816e0a4 100644 --- a/docs/swagger/paths/v1_authenticate.json +++ b/docs/swagger/paths/v1_authenticate.json @@ -25,6 +25,11 @@ "description": "The authentication token that needs to be sent in the Authorization header" } } + }, + "examples": { + "application/json": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" + } } }, "400": { diff --git a/docs/swagger/paths/v1_short-codes.json b/docs/swagger/paths/v1_short-codes.json index 14834851..bdeffe3f 100644 --- a/docs/swagger/paths/v1_short-codes.json +++ b/docs/swagger/paths/v1_short-codes.json @@ -68,6 +68,44 @@ } } } + }, + "examples": { + "application/json": { + "shortUrls": { + "data": [ + { + "shortCode": "12C18", + "originalUrl": "https://store.steampowered.com", + "dateCreated": "2016-08-21T20:34:16+02:00", + "visitsCount": 328, + "tags": [ + "games", + "tech" + ] + }, + { + "shortCode": "12Kb3", + "originalUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ] + }, + { + "shortCode": "123bA", + "originalUrl": "https://www.google.com", + "dateCreated": "2015-10-01T20:34:16+02:00", + "visitsCount": 25, + "tags": [] + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12 + } + } + } } }, "500": { diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}.json b/docs/swagger/paths/v1_short-codes_{shortCode}.json index a706f675..78e54f61 100644 --- a/docs/swagger/paths/v1_short-codes_{shortCode}.json +++ b/docs/swagger/paths/v1_short-codes_{shortCode}.json @@ -28,6 +28,11 @@ "description": "The original long URL behind the short code." } } + }, + "examples": { + "application/json": { + "longUrl": "https://shlink.io" + } } }, "400": { diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json b/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json index 3cbfc952..5420d4ee 100644 --- a/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json +++ b/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json @@ -41,6 +41,14 @@ } } } + }, + "examples": { + "application/json": { + "tags": [ + "games", + "tech" + ] + } } }, "400": { diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json b/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json index f90daf7a..ae68a8dd 100644 --- a/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json @@ -36,6 +36,32 @@ } } } + }, + "examples": { + "application/json": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "remoteAddr": "10.20.30.40", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0" + }, + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "remoteAddr": "11.22.33.44", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "remoteAddr": "110.220.5.6", + "userAgent": "some_web_crawler/1.4" + } + ] + } + } } }, "404": { diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json new file mode 100644 index 00000000..9f7cefb2 --- /dev/null +++ b/docs/swagger/paths/v1_tags.json @@ -0,0 +1,193 @@ +{ + "get": { + "tags": [ + "Tags" + ], + "summary": "List existing tags", + "description": "Returns the list of all tags used in any short URL, ordered by name", + "parameters": [ + { + "$ref": "../parameters/Authorization.json" + } + ], + "responses": { + "200": { + "description": "The list of tags", + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "examples": { + "application/json": { + "tags": { + "data": [ + "games", + "php", + "shlink", + "tech" + ] + } + } + } + }, + "500": { + "description": "Unexpected error.", + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, + + "post": { + "tags": [ + "Tags" + ], + "summary": "Create tags", + "description": "Provided a list of tags, creates all that do not yet exist", + "parameters": [ + { + "$ref": "../parameters/Authorization.json" + }, + { + "name": "tags[]", + "in": "formData", + "description": "The list of tag names to create", + "required": true, + "type": "array" + } + ], + "responses": { + "200": { + "description": "The list of tags", + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "examples": { + "application/json": { + "tags": { + "data": [ + "games", + "php", + "shlink", + "tech" + ] + } + } + } + }, + "500": { + "description": "Unexpected error.", + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, + + "put": { + "tags": [ + "Tags" + ], + "summary": "Rename tag", + "description": "Renames one existing tag", + "parameters": [ + { + "$ref": "../parameters/Authorization.json" + }, + { + "name": "oldName", + "in": "formData", + "description": "Current name of the tag", + "required": true, + "type": "string" + }, + { + "name": "newName", + "in": "formData", + "description": "New name of the tag", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": "The tag has been properly renamed" + }, + "400": { + "description": "You have not provided either the oldName or the newName params.", + "schema": { + "$ref": "../definitions/Error.json" + } + }, + "404": { + "description": "There's no tag found with the name provided in oldName param.", + "schema": { + "$ref": "../definitions/Error.json" + } + }, + "500": { + "description": "Unexpected error.", + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, + + "delete": { + "tags": [ + "Tags" + ], + "summary": "Delete tags", + "description": "Deletes provided list of tags", + "parameters": [ + { + "$ref": "../parameters/Authorization.json" + }, + { + "name": "tags[]", + "in": "query", + "description": "The names of the tags to delete", + "required": true, + "type": "array" + } + ], + "responses": { + "204": { + "description": "Tags properly deleted" + }, + "500": { + "description": "Unexpected error.", + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 6e9244f7..c7df5490 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", - "version": "1.2.0" + "version": "1.0" }, "schemes": [ "http", @@ -22,17 +22,23 @@ "/v1/authenticate": { "$ref": "paths/v1_authenticate.json" }, + "/v1/short-codes": { "$ref": "paths/v1_short-codes.json" }, "/v1/short-codes/{shortCode}": { "$ref": "paths/v1_short-codes_{shortCode}.json" }, - "/v1/short-codes/{shortCode}/visits": { - "$ref": "paths/v1_short-codes_{shortCode}_visits.json" - }, "/v1/short-codes/{shortCode}/tags": { "$ref": "paths/v1_short-codes_{shortCode}_tags.json" + }, + + "/v1/tags": { + "$ref": "paths/v1_tags.json" + }, + + "/v1/short-codes/{shortCode}/visits": { + "$ref": "paths/v1_short-codes_{shortCode}_visits.json" } } } diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 8bd88607..a1a34c16 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -1,10 +1,11 @@ [ - 'locale' => env('CLI_LOCALE', 'en'), + 'locale' => Common\env('CLI_LOCALE', 'en'), 'commands' => [ Command\Shortcode\GenerateShortcodeCommand::class, Command\Shortcode\ResolveUrlCommand::class, @@ -17,6 +18,10 @@ return [ 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, ] ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 00e56607..565ab8bc 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -21,6 +21,10 @@ return [ Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class, Command\Api\DisableKeyCommand::class => AnnotatedFactory::class, Command\Api\ListKeysCommand::class => AnnotatedFactory::class, + Command\Tag\ListTagsCommand::class => AnnotatedFactory::class, + Command\Tag\CreateTagCommand::class => AnnotatedFactory::class, + Command\Tag\RenameTagCommand::class => AnnotatedFactory::class, + Command\Tag\DeleteTagsCommand::class => AnnotatedFactory::class, ], ], diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index 3365694a..93bf6559 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 dab5bdb7..0a456205 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: 2016-10-22 23:12+0200\n" -"PO-Revision-Date: 2016-10-22 23:13+0200\n" +"POT-Creation-Date: 2017-07-16 09:35+0200\n" +"PO-Revision-Date: 2017-07-16 09:39+0200\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 1.8.7.1\n" +"X-Generator: Poedit 2.0.1\n" "X-Poedit-Basepath: ..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -223,6 +223,53 @@ msgstr "URL larga:" msgid "Provided short code \"%s\" has an invalid format." msgstr "El código corto proporcionado \"%s\" tiene un formato inválido." +msgid "Creates one or more tags." +msgstr "Crea una o más etiquetas." + +msgid "The name of the tags to create" +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 "Deletes one or more tags." +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 "Lists existing tags." +msgstr "Lista las etiquetas existentes." + +#, fuzzy +msgid "Name" +msgstr "Nombre" + +msgid "No tags yet" +msgstr "Aún no hay etiquetas" + +msgid "Renames one existing tag." +msgstr "Renombra una etiqueta existente." + +msgid "Current name of the tag." +msgstr "Nombre actual de la etiqueta." + +msgid "New name of the tag." +msgstr "Nuevo nombre de la etiqueta." + +msgid "Tag properly renamed." +msgstr "Etiqueta correctamente renombrada." + +#, php-format +msgid "A tag with name \"%s\" was not found" +msgstr "Una etiqueta con nombre \"%s\" no ha sido encontrada" + msgid "Processes visits where location is not set yet" msgstr "Procesa las visitas donde la localización no ha sido establecida aún" diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 738b8b43..48d9d564 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -32,7 +32,7 @@ class DisableKeyCommand extends Command { $this->apiKeyService = $apiKeyService; $this->translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 5e93adeb..b08c1ece 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -84,7 +84,7 @@ class ListKeysCommand extends Command $rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row)); } - $rowData[] = isset($expiration) ? $expiration->format(\DateTime::ISO8601) : '-'; + $rowData[] = isset($expiration) ? $expiration->format(\DateTime::ATOM) : '-'; $table->addRow($rowData); } diff --git a/module/CLI/src/Command/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php index b331511a..93de4305 100644 --- a/module/CLI/src/Command/Install/InstallCommand.php +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -1,27 +1,25 @@ 'pdo_mysql', - 'PostgreSQL' => 'pdo_pgsql', - 'SQLite' => 'pdo_sqlite', - ]; - const SUPPORTED_LANGUAGES = ['en', 'es']; + const GENERATED_CONFIG_PATH = 'config/params/generated_config.php'; /** * @var InputInterface @@ -43,22 +41,44 @@ class InstallCommand extends Command * @var WriterInterface */ private $configWriter; + /** + * @var Filesystem + */ + private $filesystem; + /** + * @var ConfigCustomizerPluginManagerInterface + */ + private $configCustomizers; + /** + * @var bool + */ + private $isUpdate; /** * InstallCommand constructor. * @param WriterInterface $configWriter - * @param callable|null $databaseCreationLogic + * @param Filesystem $filesystem + * @param bool $isUpdate + * @throws LogicException */ - public function __construct(WriterInterface $configWriter) - { - parent::__construct(null); + public function __construct( + WriterInterface $configWriter, + Filesystem $filesystem, + ConfigCustomizerPluginManagerInterface $configCustomizers, + $isUpdate = false + ) { + parent::__construct(); $this->configWriter = $configWriter; + $this->isUpdate = $isUpdate; + $this->filesystem = $filesystem; + $this->configCustomizers = $configCustomizers; } public function configure() { - $this->setName('shlink:install') - ->setDescription('Installs Shlink'); + $this + ->setName('shlink:install') + ->setDescription('Installs or updates Shlink'); } public function execute(InputInterface $input, OutputInterface $output) @@ -67,40 +87,58 @@ class InstallCommand extends Command $this->output = $output; $this->questionHelper = $this->getHelper('question'); $this->processHelper = $this->getHelper('process'); - $params = []; $output->writeln([ 'Welcome to Shlink!!', - 'This process will guide you through the installation.', + 'This will guide you through the installation process.', ]); // Check if a cached config file exists and drop it if so - if (file_exists('data/cache/app_config.php')) { + if ($this->filesystem->exists('data/cache/app_config.php')) { $output->write('Deleting old cached config...'); - if (unlink('data/cache/app_config.php')) { + try { + $this->filesystem->remove('data/cache/app_config.php'); $output->writeln(' Success'); - } else { + } catch (IOException $e) { $output->writeln( ' Failed! You will have to manually delete the data/cache/app_config.php file to get' . ' new config applied.' ); + if ($output->isVerbose()) { + $this->getApplication()->renderException($e, $output); + } + return; } } + // If running update command, ask the user to import previous config + $config = $this->isUpdate ? $this->importConfig() : new CustomizableAppConfig(); + // Ask for custom config params - $params['DATABASE'] = $this->askDatabase(); - $params['URL_SHORTENER'] = $this->askUrlShortener(); - $params['LANGUAGE'] = $this->askLanguage(); - $params['APP'] = $this->askApplication(); + foreach ([ + Plugin\DatabaseConfigCustomizerPlugin::class, + Plugin\UrlShortenerConfigCustomizerPlugin::class, + Plugin\LanguageConfigCustomizerPlugin::class, + Plugin\ApplicationConfigCustomizerPlugin::class, + ] as $pluginName) { + /** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */ + $configCustomizer = $this->configCustomizers->get($pluginName); + $configCustomizer->process($input, $output, $config); + } // Generate config params files - $config = $this->buildAppConfig($params); - $this->configWriter->toFile('config/params/generated_config.php', $config, false); + $this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false); $output->writeln(['Custom configuration properly generated!', '']); - // Generate database - if (! $this->createDatabase()) { - return; + // If current command is not update, generate database + if (! $this->isUpdate) { + $this->output->writeln('Initializing database...'); + if (! $this->runCommand( + 'php vendor/bin/doctrine.php orm:schema-tool:create', + 'Error generating database.' + )) { + return; + } } // Run database migrations @@ -116,105 +154,47 @@ class InstallCommand extends Command } } - protected function askDatabase() + /** + * @return CustomizableAppConfig + * @throws RuntimeException + */ + private function importConfig() { - $params = []; - $this->printTitle('DATABASE'); + $config = new CustomizableAppConfig(); - // Select database type - $databases = array_keys(self::DATABASE_DRIVERS); - $dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select database type (defaults to ' . $databases[0] . '):', - $databases, - 0 + // 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): ' )); - $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('Database name', 'shlink'); - $params['USER'] = $this->ask('Database username'); - $params['PASSWORD'] = $this->ask('Database password'); - $params['HOST'] = $this->ask('Database host', 'localhost'); - $params['PORT'] = $this->ask('Database port', $this->getDefaultDbPort($params['DRIVER'])); + if (! $importConfig) { + return $config; } - return $params; - } + // Ask the user for the older shlink path + $keepAsking = true; + do { + $config->setImportedInstallationPath($this->ask( + 'Previous shlink installation path from which to import config' + )); + $configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH; + $configExists = $this->filesystem->exists($configFile); - protected function getDefaultDbPort($driver) - { - return $driver === 'pdo_mysql' ? '3306' : '5432'; - } + 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): ' + )); + } + } while (! $configExists && $keepAsking); - protected function askUrlShortener() - { - $this->printTitle('URL SHORTENER'); + // If after some retries the user has chosen not to test another path, return + if (! $configExists) { + return $config; + } - // Ask for URL shortener params - return [ - 'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select schema for generated short URLs (defaults to http):', - ['http', 'https'], - 0 - )), - 'HOSTNAME' => $this->ask('Hostname for generated URLs'), - 'CHARS' => $this->ask( - 'Character set for generated short codes (leave empty to autogenerate one)', - null, - true - ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS) - ]; - } - - protected function askLanguage() - { - $this->printTitle('LANGUAGE'); - - return [ - 'DEFAULT' => $this->questionHelper->ask($this->input, $this->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($this->input, $this->output, new ChoiceQuestion( - 'Select default language for CLI executions (defaults to ' - . self::SUPPORTED_LANGUAGES[0] . '):', - self::SUPPORTED_LANGUAGES, - 0 - )), - ]; - } - - protected function askApplication() - { - $this->printTitle('APPLICATION'); - - return [ - 'SECRET' => $this->ask( - 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', - null, - true - ) ?: $this->generateRandomString(32), - ]; - } - - /** - * @param string $text - */ - protected function printTitle($text) - { - $text = trim($text); - $length = strlen($text) + 4; - $header = str_repeat('*', $length); - - $this->output->writeln([ - '', - '' . $header . '', - '* ' . strtoupper($text) . ' *', - '' . $header . '', - ]); + // Read the config file + $config->exchangeArray(include $configFile); + return $config; } /** @@ -222,10 +202,11 @@ class InstallCommand extends Command * @param string|null $default * @param bool $allowEmpty * @return string + * @throws RuntimeException */ - protected function ask($text, $default = null, $allowEmpty = false) + private function ask($text, $default = null, $allowEmpty = false) { - if (isset($default)) { + if ($default !== null) { $text .= ' (defaults to ' . $default . ')'; } do { @@ -236,87 +217,31 @@ class InstallCommand extends Command if (empty($value) && ! $allowEmpty) { $this->output->writeln('Value can\'t be empty'); } - } while (empty($value) && empty($default) && ! $allowEmpty); + } while (empty($value) && $default === null && ! $allowEmpty); return $value; } - /** - * @param array $params - * @return array - */ - protected function buildAppConfig(array $params) - { - // Build simple config - $config = [ - 'app_options' => [ - 'secret_key' => $params['APP']['SECRET'], - ], - 'entity_manager' => [ - 'connection' => [ - 'driver' => $params['DATABASE']['DRIVER'], - ], - ], - 'translator' => [ - 'locale' => $params['LANGUAGE']['DEFAULT'], - ], - 'cli' => [ - 'locale' => $params['LANGUAGE']['CLI'], - ], - 'url_shortener' => [ - 'domain' => [ - 'schema' => $params['URL_SHORTENER']['SCHEMA'], - 'hostname' => $params['URL_SHORTENER']['HOSTNAME'], - ], - 'shortcode_chars' => $params['URL_SHORTENER']['CHARS'], - ], - ]; - - // Build dynamic database config - if ($params['DATABASE']['DRIVER'] === 'pdo_sqlite') { - $config['entity_manager']['connection']['path'] = 'data/database.sqlite'; - } else { - $config['entity_manager']['connection']['user'] = $params['DATABASE']['USER']; - $config['entity_manager']['connection']['password'] = $params['DATABASE']['PASSWORD']; - $config['entity_manager']['connection']['dbname'] = $params['DATABASE']['NAME']; - $config['entity_manager']['connection']['host'] = $params['DATABASE']['HOST']; - $config['entity_manager']['connection']['port'] = $params['DATABASE']['PORT']; - - if ($params['DATABASE']['DRIVER'] === 'pdo_mysql') { - $config['entity_manager']['connection']['driverOptions'] = [ - \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', - ]; - } - } - - return $config; - } - - protected function createDatabase() - { - $this->output->writeln('Initializing database...'); - return $this->runCommand('php vendor/bin/doctrine.php orm:schema-tool:create', 'Error generating database.'); - } - /** * @param string $command * @param string $errorMessage * @return bool */ - protected function runCommand($command, $errorMessage) + private function runCommand($command, $errorMessage) { $process = $this->processHelper->run($this->output, $command); if ($process->isSuccessful()) { $this->output->writeln(' Success!'); return true; - } else { - if ($this->output->isVerbose()) { - return false; - } - $this->output->writeln( - ' ' . $errorMessage . ' Run this command with -vvv to see specific error info.' - ); + } + + if ($this->output->isVerbose()) { return false; } + + $this->output->writeln( + ' ' . $errorMessage . ' Run this command with -vvv to see specific error info.' + ); + return false; } } diff --git a/module/CLI/src/Command/Install/UpdateCommand.php b/module/CLI/src/Command/Install/UpdateCommand.php deleted file mode 100644 index 3aed5512..00000000 --- a/module/CLI/src/Command/Install/UpdateCommand.php +++ /dev/null @@ -1,12 +0,0 @@ -tagService = $tagService; + $this->translator = $translator; + parent::__construct(); + } + + protected function configure() + { + $this + ->setName('tag:create') + ->setDescription($this->translator->translate('Creates one or more tags.')) + ->addOption( + 'name', + 't', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + $this->translator->translate('The name of the tags to create') + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $tagNames = $input->getOption('name'); + if (empty($tagNames)) { + $output->writeln(sprintf( + '%s', + $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 + ))); + } +} diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php new file mode 100644 index 00000000..0a4e271b --- /dev/null +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -0,0 +1,69 @@ +tagService = $tagService; + $this->translator = $translator; + parent::__construct(); + } + + protected function configure() + { + $this + ->setName('tag:delete') + ->setDescription($this->translator->translate('Deletes one or more tags.')) + ->addOption( + 'name', + 't', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + $this->translator->translate('The name of the tags to delete') + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $tagNames = $input->getOption('name'); + if (empty($tagNames)) { + $output->writeln(sprintf( + '%s', + $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 + ))); + } +} diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php new file mode 100644 index 00000000..eb120226 --- /dev/null +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -0,0 +1,67 @@ +tagService = $tagService; + $this->translator = $translator; + parent::__construct(); + } + + protected function configure() + { + $this + ->setName('tag:list') + ->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(); + } + + private function getTagsRows() + { + $tags = $this->tagService->listTags(); + if (empty($tags)) { + return [[$this->translator->translate('No tags yet')]]; + } + + 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 new file mode 100644 index 00000000..e3ee678e --- /dev/null +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -0,0 +1,63 @@ +tagService = $tagService; + $this->translator = $translator; + parent::__construct(); + } + + protected function configure() + { + $this + ->setName('tag:rename') + ->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.')); + } + + protected function execute(InputInterface $input, OutputInterface $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.'))); + } catch (EntityDoesNotExistException $e) { + $output->writeln('' . sprintf($this->translator->translate( + 'A tag with name "%s" was not found' + ), $oldName) . ''); + } + } +} diff --git a/module/CLI/src/Factory/InstallApplicationFactory.php b/module/CLI/src/Factory/InstallApplicationFactory.php new file mode 100644 index 00000000..742b5b70 --- /dev/null +++ b/module/CLI/src/Factory/InstallApplicationFactory.php @@ -0,0 +1,55 @@ +get(Filesystem::class), + new ConfigCustomizerPluginManager($container, ['factories' => [ + Plugin\DatabaseConfigCustomizerPlugin::class => AnnotatedFactory::class, + Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + ]]), + $isUpdate + ); + $app->add($command); + $app->setDefaultCommand($command->getName()); + + return $app; + } +} diff --git a/module/CLI/src/Install/ConfigCustomizerPluginManager.php b/module/CLI/src/Install/ConfigCustomizerPluginManager.php new file mode 100644 index 00000000..c8f0e7cb --- /dev/null +++ b/module/CLI/src/Install/ConfigCustomizerPluginManager.php @@ -0,0 +1,10 @@ +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/ApplicationConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php new file mode 100644 index 00000000..936381b0 --- /dev/null +++ b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php @@ -0,0 +1,41 @@ +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/ConfigCustomizerPluginInterface.php b/module/CLI/src/Install/Plugin/ConfigCustomizerPluginInterface.php new file mode 100644 index 00000000..2f1c60e1 --- /dev/null +++ b/module/CLI/src/Install/Plugin/ConfigCustomizerPluginInterface.php @@ -0,0 +1,17 @@ + '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 new file mode 100644 index 00000000..78e5a87a --- /dev/null +++ b/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php @@ -0,0 +1,29 @@ +get(QuestionHelper::class)); + } +} diff --git a/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php new file mode 100644 index 00000000..91b5c7ec --- /dev/null +++ b/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php @@ -0,0 +1,47 @@ +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/UrlShortenerConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php new file mode 100644 index 00000000..ac9c32e9 --- /dev/null +++ b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php @@ -0,0 +1,48 @@ +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) + ]); + } +} diff --git a/module/CLI/src/Model/CustomizableAppConfig.php b/module/CLI/src/Model/CustomizableAppConfig.php new file mode 100644 index 00000000..03836e0f --- /dev/null +++ b/module/CLI/src/Model/CustomizableAppConfig.php @@ -0,0 +1,265 @@ +database; + } + + /** + * @param array $database + * @return $this + */ + public function setDatabase(array $database) + { + $this->database = $database; + return $this; + } + + /** + * @return bool + */ + public function hasDatabase() + { + return ! empty($this->database); + } + + /** + * @return array + */ + public function getUrlShortener() + { + return $this->urlShortener; + } + + /** + * @param array $urlShortener + * @return $this + */ + public function setUrlShortener(array $urlShortener) + { + $this->urlShortener = $urlShortener; + return $this; + } + + /** + * @return bool + */ + public function hasUrlShortener() + { + return ! empty($this->urlShortener); + } + + /** + * @return array + */ + public function getLanguage() + { + return $this->language; + } + + /** + * @param array $language + * @return $this + */ + public function setLanguage(array $language) + { + $this->language = $language; + return $this; + } + + /** + * @return bool + */ + public function hasLanguage() + { + return ! empty($this->language); + } + + /** + * @return array + */ + public function getApp() + { + return $this->app; + } + + /** + * @param array $app + * @return $this + */ + public function setApp(array $app) + { + $this->app = $app; + return $this; + } + + /** + * @return bool + */ + public function hasApp() + { + return ! empty($this->app); + } + + /** + * @return string + */ + public function getImportedInstallationPath() + { + return $this->importedInstallationPath; + } + + /** + * @param string $importedInstallationPath + * @return $this|self + */ + public function setImportedInstallationPath($importedInstallationPath) + { + $this->importedInstallationPath = $importedInstallationPath; + return $this; + } + + /** + * @return bool + */ + public function hasImportedInstallationPath() + { + return $this->importedInstallationPath !== null; + } + + /** + * Exchange internal values from provided array + * + * @param array $array + * @return void + */ + public function exchangeArray(array $array) + { + if (isset($array['app_options'], $array['app_options']['secret_key'])) { + $this->setApp([ + 'SECRET' => $array['app_options']['secret_key'], + ]); + } + + if (isset($array['entity_manager'], $array['entity_manager']['connection'])) { + $this->deserializeDatabase($array['entity_manager']['connection']); + } + + if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) { + $this->setLanguage([ + 'DEFAULT' => $array['translator']['locale'], + 'CLI' => $array['cli']['locale'], + ]); + } + + if (isset($array['url_shortener'])) { + $urlShortener = $array['url_shortener']; + $this->setUrlShortener([ + 'SCHEMA' => $urlShortener['domain']['schema'], + 'HOSTNAME' => $urlShortener['domain']['hostname'], + 'CHARS' => $urlShortener['shortcode_chars'], + ]); + } + } + + private function deserializeDatabase(array $conn) + { + if (! isset($conn['driver'])) { + return; + } + $driver = $conn['driver']; + + $params = ['DRIVER' => $driver]; + if ($driver !== 'pdo_sqlite') { + $params['USER'] = $conn['user']; + $params['PASSWORD'] = $conn['password']; + $params['NAME'] = $conn['dbname']; + $params['HOST'] = $conn['host']; + $params['PORT'] = $conn['port']; + } + + $this->setDatabase($params); + } + + /** + * Return an array representation of the object + * + * @return array + */ + public function getArrayCopy() + { + $config = [ + 'app_options' => [ + 'secret_key' => $this->app['SECRET'], + ], + 'entity_manager' => [ + 'connection' => [ + 'driver' => $this->database['DRIVER'], + ], + ], + 'translator' => [ + 'locale' => $this->language['DEFAULT'], + ], + 'cli' => [ + 'locale' => $this->language['CLI'], + ], + 'url_shortener' => [ + 'domain' => [ + 'schema' => $this->urlShortener['SCHEMA'], + 'hostname' => $this->urlShortener['HOSTNAME'], + ], + 'shortcode_chars' => $this->urlShortener['CHARS'], + ], + ]; + + // Build dynamic database config based on selected driver + if ($this->database['DRIVER'] === 'pdo_sqlite') { + $config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH; + } else { + $config['entity_manager']['connection']['user'] = $this->database['USER']; + $config['entity_manager']['connection']['password'] = $this->database['PASSWORD']; + $config['entity_manager']['connection']['dbname'] = $this->database['NAME']; + $config['entity_manager']['connection']['host'] = $this->database['HOST']; + $config['entity_manager']['connection']['port'] = $this->database['PORT']; + + if ($this->database['DRIVER'] === 'pdo_mysql') { + $config['entity_manager']['connection']['driverOptions'] = [ + \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', + ]; + } + } + + return $config; + } +} diff --git a/module/CLI/test-resources/config/params/generated_config.php b/module/CLI/test-resources/config/params/generated_config.php new file mode 100644 index 00000000..881ab67d --- /dev/null +++ b/module/CLI/test-resources/config/params/generated_config.php @@ -0,0 +1,2 @@ +setHelperSet(Argument::any())->willReturn(null); $processHelper->run(Argument::cetera())->willReturn($processMock->reveal()); + $this->filesystem = $this->prophesize(Filesystem::class); + $this->filesystem->exists(Argument::cetera())->willReturn(false); + + $this->configWriter = $this->prophesize(WriterInterface::class); + + $configCustomizer = $this->prophesize(ConfigCustomizerPluginInterface::class); + $configCustomizers = $this->prophesize(ConfigCustomizerPluginManagerInterface::class); + $configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal()); + $app = new Application(); $helperSet = $app->getHelperSet(); $helperSet->set($processHelper->reveal()); $app->setHelperSet($helperSet); - - $this->configWriter = $this->prophesize(WriterInterface::class); - $command = new InstallCommand($this->configWriter->reveal()); - $app->add($command); - - $questionHelper = $command->getHelper('question'); - $questionHelper->setInputStream($this->createInputStream()); - $this->commandTester = new CommandTester($command); - } - - protected function createInputStream() - { - $stream = fopen('php://memory', 'rb+', false); - fwrite($stream, <<command = new InstallCommand( + $this->configWriter->reveal(), + $this->filesystem->reveal(), + $configCustomizers->reveal() ); - rewind($stream); + $app->add($this->command); - return $stream; + $this->commandTester = new CommandTester($this->command); } /** * @test */ - public function inputIsProperlyParsed() + public function generatedConfigIsProperlyPersisted() { - $this->configWriter->toFile(Argument::any(), [ - 'app_options' => [ - 'secret_key' => 'my_secret', - ], - 'entity_manager' => [ - 'connection' => [ - 'driver' => 'pdo_mysql', - 'dbname' => 'shlink_db', - 'user' => 'alejandro', - 'password' => '1234', - 'host' => 'localhost', - 'port' => '3306', - 'driverOptions' => [ - \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', - ] - ], - ], - 'translator' => [ - 'locale' => 'en', - ], - 'cli' => [ - 'locale' => 'es', - ], - 'url_shortener' => [ - 'domain' => [ - 'schema' => 'http', - 'hostname' => 'doma.in', - ], - 'shortcode_chars' => 'abc123BCA', - ], - ], false)->shouldBeCalledTimes(1); - $this->commandTester->execute([ - 'command' => 'shlink:install', + $this->configWriter->toFile(Argument::any(), Argument::type('array'), false)->shouldBeCalledTimes(1); + $this->commandTester->execute([]); + } + + /** + * @test + */ + public function cachedConfigIsDeletedIfExists() + { + /** @var MethodProphecy $appConfigExists */ + $appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true); + /** @var MethodProphecy $appConfigRemove */ + $appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willReturn(null); + + $this->commandTester->execute([]); + + $appConfigExists->shouldHaveBeenCalledTimes(1); + $appConfigRemove->shouldHaveBeenCalledTimes(1); + } + + /** + * @test + */ + public function exceptionWhileDeletingCachedConfigCancelsProcess() + { + /** @var MethodProphecy $appConfigExists */ + $appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true); + /** @var MethodProphecy $appConfigRemove */ + $appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willThrow(IOException::class); + /** @var MethodProphecy $configToFile */ + $configToFile = $this->configWriter->toFile(Argument::cetera())->willReturn(true); + + $this->commandTester->execute([]); + + $appConfigExists->shouldHaveBeenCalledTimes(1); + $appConfigRemove->shouldHaveBeenCalledTimes(1); + $configToFile->shouldNotHaveBeenCalled(); + } + + /** + * @test + */ + public function whenCommandIsUpdatePreviousConfigCanBeImported() + { + $ref = new \ReflectionObject($this->command); + $prop = $ref->getProperty('isUpdate'); + $prop->setAccessible(true); + $prop->setValue($this->command, true); + + /** @var MethodProphecy $importedConfigExists */ + $importedConfigExists = $this->filesystem->exists( + __DIR__ . '/../../../test-resources/' . InstallCommand::GENERATED_CONFIG_PATH + )->willReturn(true); + + $this->commandTester->setInputs([ + '', + '/foo/bar/wrong_previous_shlink', + '', + __DIR__ . '/../../../test-resources', ]); + $this->commandTester->execute([]); + + $importedConfigExists->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php new file mode 100644 index 00000000..5042e695 --- /dev/null +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -0,0 +1,67 @@ +tagService = $this->prophesize(TagServiceInterface::class); + + $command = new CreateTagCommand($this->tagService->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function errorIsReturnedWhenNoTagsAreProvided() + { + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertContains('You have to provide at least one tag name', $output); + } + + /** + * @test + */ + public function serviceIsInvokedOnSuccess() + { + $tagNames = ['foo', 'bar']; + /** @var MethodProphecy $createTags */ + $createTags = $this->tagService->createTags($tagNames)->willReturn([]); + + $this->commandTester->execute([ + '--name' => $tagNames, + ]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains(sprintf('Created tags: ["%s"]', implode('", "', $tagNames)), $output); + $createTags->shouldHaveBeenCalled(); + } +} diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php new file mode 100644 index 00000000..9498a450 --- /dev/null +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -0,0 +1,68 @@ +tagService = $this->prophesize(TagServiceInterface::class); + + $command = new DeleteTagsCommand($this->tagService->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function errorIsReturnedWhenNoTagsAreProvided() + { + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertContains('You have to provide at least one tag name', $output); + } + + /** + * @test + */ + public function serviceIsInvokedOnSuccess() + { + $tagNames = ['foo', 'bar']; + /** @var MethodProphecy $deleteTags */ + $deleteTags = $this->tagService->deleteTags($tagNames)->will(function () { + }); + + $this->commandTester->execute([ + '--name' => $tagNames, + ]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains(sprintf('Deleted tags: ["%s"]', implode('", "', $tagNames)), $output); + $deleteTags->shouldHaveBeenCalled(); + } +} diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php new file mode 100644 index 00000000..a4adc55d --- /dev/null +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -0,0 +1,73 @@ +tagService = $this->prophesize(TagServiceInterface::class); + + $command = new ListTagsCommand($this->tagService->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function noTagsPrintsEmptyMessage() + { + /** @var MethodProphecy $listTags */ + $listTags = $this->tagService->listTags()->willReturn([]); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains('No tags yet', $output); + $listTags->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function listOfTagsIsPrinted() + { + /** @var MethodProphecy $listTags */ + $listTags = $this->tagService->listTags()->willReturn([ + (new Tag())->setName('foo'), + (new Tag())->setName('bar'), + ]); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains('foo', $output); + $this->assertContains('bar', $output); + $listTags->shouldHaveBeenCalled(); + } +} diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php new file mode 100644 index 00000000..5bf56f64 --- /dev/null +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -0,0 +1,80 @@ +tagService = $this->prophesize(TagServiceInterface::class); + + $command = new RenameTagCommand($this->tagService->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function errorIsPrintedIfExceptionIsThrown() + { + $oldName = 'foo'; + $newName = 'bar'; + /** @var MethodProphecy $renameTag */ + $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class); + + $this->commandTester->execute([ + 'oldName' => $oldName, + 'newName' => $newName, + ]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains('A tag with name "foo" was not found', $output); + $renameTag->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function successIsPrintedIfNoErrorOccurs() + { + $oldName = 'foo'; + $newName = 'bar'; + /** @var MethodProphecy $renameTag */ + $renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag()); + + $this->commandTester->execute([ + 'oldName' => $oldName, + 'newName' => $newName, + ]); + $output = $this->commandTester->getDisplay(); + + $this->assertContains('Tag properly renamed', $output); + $renameTag->shouldHaveBeenCalled(); + } +} diff --git a/module/CLI/test/Factory/InstallApplicationFactoryTest.php b/module/CLI/test/Factory/InstallApplicationFactoryTest.php new file mode 100644 index 00000000..c7e87bc7 --- /dev/null +++ b/module/CLI/test/Factory/InstallApplicationFactoryTest.php @@ -0,0 +1,33 @@ +factory = new InstallApplicationFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(['services' => [ + Filesystem::class => $this->prophesize(Filesystem::class)->reveal(), + ]]), ''); + + $this->assertInstanceOf(Application::class, $instance); + } +} diff --git a/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php new file mode 100644 index 00000000..2b30a328 --- /dev/null +++ b/module/CLI/test/Install/Plugin/ApplicationConfigCustomizerPluginTest.php @@ -0,0 +1,93 @@ +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/DatabaseConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerPluginTest.php new file mode 100644 index 00000000..85d18225 --- /dev/null +++ b/module/CLI/test/Install/Plugin/DatabaseConfigCustomizerPluginTest.php @@ -0,0 +1,152 @@ +questionHelper = $this->prophesize(QuestionHelper::class); + $this->filesystem = $this->prophesize(Filesystem::class); + + $this->plugin = new DatabaseConfigCustomizerPlugin( + $this->questionHelper->reveal(), + $this->filesystem->reveal() + ); + } + + /** + * @test + */ + public function configIsRequestedToTheUser() + { + /** @var MethodProphecy $askSecret */ + $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('MySQL'); + $config = new CustomizableAppConfig(); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertTrue($config->hasDatabase()); + $this->assertEquals([ + 'DRIVER' => 'pdo_mysql', + 'NAME' => 'MySQL', + 'USER' => 'MySQL', + 'PASSWORD' => 'MySQL', + 'HOST' => 'MySQL', + 'PORT' => 'MySQL', + ], $config->getDatabase()); + $askSecret->shouldHaveBeenCalledTimes(6); + } + + /** + * @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 : 'MySQL'; + }); + $config = new CustomizableAppConfig(); + $config->setDatabase([ + 'DRIVER' => 'pdo_pgsql', + 'NAME' => 'MySQL', + 'USER' => 'MySQL', + 'PASSWORD' => 'MySQL', + 'HOST' => 'MySQL', + 'PORT' => 'MySQL', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'DRIVER' => 'pdo_mysql', + 'NAME' => 'MySQL', + 'USER' => 'MySQL', + 'PASSWORD' => 'MySQL', + 'HOST' => 'MySQL', + 'PORT' => 'MySQL', + ], $config->getDatabase()); + $ask->shouldHaveBeenCalledTimes(7); + } + + /** + * @test + */ + public function existingValueIsKeptIfRequested() + { + /** @var MethodProphecy $ask */ + $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + + $config = new CustomizableAppConfig(); + $config->setDatabase([ + 'DRIVER' => 'pdo_pgsql', + 'NAME' => 'MySQL', + 'USER' => 'MySQL', + 'PASSWORD' => 'MySQL', + 'HOST' => 'MySQL', + 'PORT' => 'MySQL', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'DRIVER' => 'pdo_pgsql', + 'NAME' => 'MySQL', + 'USER' => 'MySQL', + 'PASSWORD' => 'MySQL', + 'HOST' => 'MySQL', + 'PORT' => 'MySQL', + ], $config->getDatabase()); + $ask->shouldHaveBeenCalledTimes(1); + } + + /** + * @test + */ + public function sqliteDatabaseIsImportedWhenRequested() + { + /** @var MethodProphecy $ask */ + $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + /** @var MethodProphecy $copy */ + $copy = $this->filesystem->copy(Argument::cetera())->willReturn(null); + + $config = new CustomizableAppConfig(); + $config->setDatabase([ + 'DRIVER' => 'pdo_sqlite', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'DRIVER' => 'pdo_sqlite', + ], $config->getDatabase()); + $ask->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 new file mode 100644 index 00000000..e5004dcc --- /dev/null +++ b/module/CLI/test/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactoryTest.php @@ -0,0 +1,38 @@ +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/LanguageConfigCustomizerPluginTest.php new file mode 100644 index 00000000..77cae092 --- /dev/null +++ b/module/CLI/test/Install/Plugin/LanguageConfigCustomizerPluginTest.php @@ -0,0 +1,98 @@ +questionHelper = $this->prophesize(QuestionHelper::class); + $this->plugin = new LanguageConfigCustomizerPlugin($this->questionHelper->reveal()); + } + + /** + * @test + */ + public function configIsRequestedToTheUser() + { + /** @var MethodProphecy $askSecret */ + $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('en'); + $config = new CustomizableAppConfig(); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertTrue($config->hasLanguage()); + $this->assertEquals([ + 'DEFAULT' => 'en', + 'CLI' => 'en', + ], $config->getLanguage()); + $askSecret->shouldHaveBeenCalledTimes(2); + } + + /** + * @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 : 'es'; + }); + $config = new CustomizableAppConfig(); + $config->setLanguage([ + 'DEFAULT' => 'en', + 'CLI' => 'en', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'DEFAULT' => 'es', + 'CLI' => 'es', + ], $config->getLanguage()); + $ask->shouldHaveBeenCalledTimes(3); + } + + /** + * @test + */ + public function existingValueIsKeptIfRequested() + { + /** @var MethodProphecy $ask */ + $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + + $config = new CustomizableAppConfig(); + $config->setLanguage([ + 'DEFAULT' => 'es', + 'CLI' => 'es', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'DEFAULT' => 'es', + 'CLI' => 'es', + ], $config->getLanguage()); + $ask->shouldHaveBeenCalledTimes(1); + } +} diff --git a/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php b/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php new file mode 100644 index 00000000..85eaf681 --- /dev/null +++ b/module/CLI/test/Install/Plugin/UrlShortenerConfigCustomizerPluginTest.php @@ -0,0 +1,103 @@ +questionHelper = $this->prophesize(QuestionHelper::class); + $this->plugin = new UrlShortenerConfigCustomizerPlugin($this->questionHelper->reveal()); + } + + /** + * @test + */ + public function configIsRequestedToTheUser() + { + /** @var MethodProphecy $askSecret */ + $askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('something'); + $config = new CustomizableAppConfig(); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertTrue($config->hasUrlShortener()); + $this->assertEquals([ + 'SCHEMA' => 'something', + 'HOSTNAME' => 'something', + 'CHARS' => 'something', + ], $config->getUrlShortener()); + $askSecret->shouldHaveBeenCalledTimes(3); + } + + /** + * @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 : 'foo'; + }); + $config = new CustomizableAppConfig(); + $config->setUrlShortener([ + 'SCHEMA' => 'bar', + 'HOSTNAME' => 'bar', + 'CHARS' => 'bar', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'SCHEMA' => 'foo', + 'HOSTNAME' => 'foo', + 'CHARS' => 'foo', + ], $config->getUrlShortener()); + $ask->shouldHaveBeenCalledTimes(4); + } + + /** + * @test + */ + public function existingValueIsKeptIfRequested() + { + /** @var MethodProphecy $ask */ + $ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true); + + $config = new CustomizableAppConfig(); + $config->setUrlShortener([ + 'SCHEMA' => 'foo', + 'HOSTNAME' => 'foo', + 'CHARS' => 'foo', + ]); + + $this->plugin->process(new ArrayInput([]), new NullOutput(), $config); + + $this->assertEquals([ + 'SCHEMA' => 'foo', + 'HOSTNAME' => 'foo', + 'CHARS' => 'foo', + ], $config->getUrlShortener()); + $ask->shouldHaveBeenCalledTimes(1); + } +} diff --git a/module/Common/functions/functions.php b/module/Common/functions/functions.php index f0f86f77..4956ddf0 100644 --- a/module/Common/functions/functions.php +++ b/module/Common/functions/functions.php @@ -1,36 +1,36 @@ get('config'); - if (isset($config['cache']) - && isset($config['cache']['adapter']) + if (isset($config['cache'], $config['cache']['adapter']) && in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS) ) { return $this->resolveCacheAdapter($config['cache']); } // If the adapter has not been set in config, create one based on environment - return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache(); + return Common\env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache(); } /** @@ -80,7 +80,7 @@ class CacheFactory implements FactoryInterface if (! isset($server['host'])) { continue; } - $port = isset($server['port']) ? intval($server['port']) : 11211; + $port = isset($server['port']) ? (int) $server['port'] : 11211; $memcached->addServer($server['host'], $port); } diff --git a/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php b/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php new file mode 100644 index 00000000..1551d8cd --- /dev/null +++ b/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php @@ -0,0 +1,30 @@ +factory = new EmptyResponseImplicitOptionsMiddlewareFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(ImplicitOptionsMiddleware::class, $instance); + } + + /** + * @test + */ + public function responsePrototypeIsEmptyResponse() + { + $instance = $this->factory->__invoke(new ServiceManager(), ''); + + $ref = new \ReflectionObject($instance); + $prop = $ref->getProperty('response'); + $prop->setAccessible(true); + $this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index f5c75bac..1ef7257c 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -16,6 +16,7 @@ return [ Service\VisitsTracker::class => AnnotatedFactory::class, Service\ShortUrlService::class => AnnotatedFactory::class, Service\VisitService::class => AnnotatedFactory::class, + Service\Tag\TagService::class => AnnotatedFactory::class, // Middleware Action\RedirectAction::class => AnnotatedFactory::class, diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index cf1ed87e..44e992ce 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -176,7 +176,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable return [ 'shortCode' => $this->shortCode, 'originalUrl' => $this->originalUrl, - 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null, + 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ATOM) : null, 'visitsCount' => count($this->visits), 'tags' => $this->tags->toArray(), ]; diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 47123ff9..7537b8c0 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -3,13 +3,14 @@ namespace Shlinkio\Shlink\Core\Entity; use Doctrine\ORM\Mapping as ORM; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Core\Repository\TagRepository; /** * Class Tag * @author * @link * - * @ORM\Entity() + * @ORM\Entity(repositoryClass=TagRepository::class) * @ORM\Table(name="tags") */ class Tag extends AbstractEntity implements \JsonSerializable @@ -20,6 +21,11 @@ class Tag extends AbstractEntity implements \JsonSerializable */ protected $name; + public function __construct($name = null) + { + $this->name = $name; + } + /** * @return string */ diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index a95c61b0..c1ab7333 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -171,7 +171,7 @@ class Visit extends AbstractEntity implements \JsonSerializable { return [ 'referer' => $this->referer, - 'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null, + 'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null, 'remoteAddr' => $this->remoteAddr, 'userAgent' => $this->userAgent, 'visitLocation' => $this->visitLocation, diff --git a/module/Core/src/Exception/EntityDoesNotExistException.php b/module/Core/src/Exception/EntityDoesNotExistException.php new file mode 100644 index 00000000..27825690 --- /dev/null +++ b/module/Core/src/Exception/EntityDoesNotExistException.php @@ -0,0 +1,26 @@ + $value) { + $result[] = sprintf('"%s" => "%s"', $key, $value); + } + + return implode(', ', $result); + } +} diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php new file mode 100644 index 00000000..46967f92 --- /dev/null +++ b/module/Core/src/Repository/TagRepository.php @@ -0,0 +1,27 @@ +getEntityManager()->createQueryBuilder(); + $qb->delete(Tag::class, 't') + ->where($qb->expr()->in('t.name', $names)); + + return $qb->getQuery()->execute(); + } +} diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php new file mode 100644 index 00000000..f3e604a5 --- /dev/null +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -0,0 +1,15 @@ +em = $em; + } + + /** + * @return Tag[] + * @throws \UnexpectedValueException + */ + public function listTags() + { + return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']); + } + + /** + * @param array $tagNames + * @return void + */ + public function deleteTags(array $tagNames) + { + /** @var TagRepository $repo */ + $repo = $this->em->getRepository(Tag::class); + $repo->deleteByName($tagNames); + } + + /** + * Provided a list of tag names, creates all that do not exist yet + * + * @param string[] $tagNames + * @return Collection|Tag[] + */ + public function createTags(array $tagNames) + { + $tags = $this->tagNamesToEntities($this->em, $tagNames); + $this->em->flush(); + + return $tags; + } + + /** + * @param string $oldName + * @param string $newName + * @return Tag + * @throws EntityDoesNotExistException + */ + public function renameTag($oldName, $newName) + { + $criteria = ['name' => $oldName]; + /** @var Tag|null $tag */ + $tag = $this->em->getRepository(Tag::class)->findOneBy($criteria); + if ($tag === null) { + throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria); + } + + $tag->setName($newName); + $this->em->flush($tag); + + return $tag; + } +} diff --git a/module/Core/src/Service/Tag/TagServiceInterface.php b/module/Core/src/Service/Tag/TagServiceInterface.php new file mode 100644 index 00000000..48714309 --- /dev/null +++ b/module/Core/src/Service/Tag/TagServiceInterface.php @@ -0,0 +1,36 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->service = new TagService($this->em->reveal()); + } + + /** + * @test + */ + public function listTagsDelegatesOnRepository() + { + $expected = [new Tag(), new Tag()]; + + $repo = $this->prophesize(EntityRepository::class); + /** @var MethodProphecy $find */ + $find = $repo->findBy(Argument::cetera())->willReturn($expected); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $result = $this->service->listTags(); + + $this->assertEquals($expected, $result); + $find->shouldHaveBeenCalled(); + $getRepo->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function deleteTagsDelegatesOnRepository() + { + $repo = $this->prophesize(TagRepository::class); + /** @var MethodProphecy $delete */ + $delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $this->service->deleteTags(['foo', 'bar']); + + $delete->shouldHaveBeenCalled(); + $getRepo->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function createTagsPersistsEntities() + { + $repo = $this->prophesize(TagRepository::class); + /** @var MethodProphecy $find */ + $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag()); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + /** @var MethodProphecy $persist */ + $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null); + /** @var MethodProphecy $flush */ + $flush = $this->em->flush()->willReturn(null); + + $result = $this->service->createTags(['foo', 'bar']); + + $this->assertCount(2, $result); + $find->shouldHaveBeenCalled(); + $getRepo->shouldHaveBeenCalled(); + $persist->shouldHaveBeenCalledTimes(2); + $flush->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function renameInvalidTagThrowsException() + { + $repo = $this->prophesize(TagRepository::class); + /** @var MethodProphecy $find */ + $find = $repo->findOneBy(Argument::cetera())->willReturn(null); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $find->shouldBeCalled(); + $getRepo->shouldBeCalled(); + $this->expectException(EntityDoesNotExistException::class); + + $this->service->renameTag('foo', 'bar'); + } + + /** + * @test + */ + public function renameValidTagChangesItsName() + { + $expected = new Tag(); + + $repo = $this->prophesize(TagRepository::class); + /** @var MethodProphecy $find */ + $find = $repo->findOneBy(Argument::cetera())->willReturn($expected); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + /** @var MethodProphecy $flush */ + $flush = $this->em->flush($expected)->willReturn(null); + + $tag = $this->service->renameTag('foo', 'bar'); + + $this->assertSame($expected, $tag); + $this->assertEquals('bar', $tag->getName()); + $find->shouldHaveBeenCalled(); + $getRepo->shouldHaveBeenCalled(); + $flush->shouldHaveBeenCalled(); + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 3fae9af7..a36f7590 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -18,7 +18,11 @@ return [ Action\ResolveUrlAction::class => AnnotatedFactory::class, Action\GetVisitsAction::class => AnnotatedFactory::class, Action\ListShortcodesAction::class => AnnotatedFactory::class, - Action\EditTagsAction::class => AnnotatedFactory::class, + Action\EditShortcodeTagsAction::class => AnnotatedFactory::class, + Action\Tag\ListTagsAction::class => AnnotatedFactory::class, + Action\Tag\DeleteTagsAction::class => AnnotatedFactory::class, + Action\Tag\CreateTagsAction::class => AnnotatedFactory::class, + Action\Tag\UpdateTagAction::class => AnnotatedFactory::class, Middleware\BodyParserMiddleware::class => AnnotatedFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class, diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 8d107d8b..0922e18a 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -1,44 +1,75 @@ [ [ - 'name' => 'rest-authenticate', + 'name' => Action\AuthenticateAction::class, 'path' => '/rest/v{version:1}/authenticate', 'middleware' => Action\AuthenticateAction::class, - 'allowed_methods' => ['POST', 'OPTIONS'], + 'allowed_methods' => [RequestMethod::METHOD_POST], ], + + // Short codes [ - 'name' => 'rest-create-shortcode', + 'name' => Action\CreateShortcodeAction::class, 'path' => '/rest/v{version:1}/short-codes', 'middleware' => Action\CreateShortcodeAction::class, - 'allowed_methods' => ['POST', 'OPTIONS'], + 'allowed_methods' => [RequestMethod::METHOD_POST], ], [ - 'name' => 'rest-resolve-url', + 'name' => Action\ResolveUrlAction::class, 'path' => '/rest/v{version:1}/short-codes/{shortCode}', 'middleware' => Action\ResolveUrlAction::class, - 'allowed_methods' => ['GET', 'OPTIONS'], + 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ - 'name' => 'rest-list-shortened-url', + 'name' => Action\ListShortcodesAction::class, 'path' => '/rest/v{version:1}/short-codes', 'middleware' => Action\ListShortcodesAction::class, - 'allowed_methods' => ['GET'], + 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ - 'name' => 'rest-get-visits', + 'name' => Action\EditShortcodeTagsAction::class, + 'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags', + 'middleware' => Action\EditShortcodeTagsAction::class, + 'allowed_methods' => [RequestMethod::METHOD_PUT], + ], + + // Visits + [ + 'name' => Action\GetVisitsAction::class, 'path' => '/rest/v{version:1}/short-codes/{shortCode}/visits', 'middleware' => Action\GetVisitsAction::class, - 'allowed_methods' => ['GET', 'OPTIONS'], + 'allowed_methods' => [RequestMethod::METHOD_GET], + ], + + // Tags + [ + 'name' => Action\Tag\ListTagsAction::class, + 'path' => '/rest/v{version:1}/tags', + 'middleware' => Action\Tag\ListTagsAction::class, + 'allowed_methods' => [RequestMethod::METHOD_GET], ], [ - 'name' => 'rest-edit-tags', - 'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags', - 'middleware' => Action\EditTagsAction::class, - 'allowed_methods' => ['PUT', 'OPTIONS'], + 'name' => Action\Tag\DeleteTagsAction::class, + 'path' => '/rest/v{version:1}/tags', + 'middleware' => Action\Tag\DeleteTagsAction::class, + 'allowed_methods' => [RequestMethod::METHOD_DELETE], + ], + [ + 'name' => Action\Tag\CreateTagsAction::class, + 'path' => '/rest/v{version:1}/tags', + 'middleware' => Action\Tag\CreateTagsAction::class, + 'allowed_methods' => [RequestMethod::METHOD_POST], + ], + [ + 'name' => Action\Tag\UpdateTagAction::class, + 'path' => '/rest/v{version:1}/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 070394c0..a7b89c77 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 b824cf72..dc46df88 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: 2016-08-21 18:17+0200\n" -"PO-Revision-Date: 2016-08-21 18:17+0200\n" +"POT-Creation-Date: 2017-07-16 09:39+0200\n" +"PO-Revision-Date: 2017-07-16 09:40+0200\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 1.8.7.1\n" +"X-Generator: Poedit 2.0.1\n" "X-Poedit-Basepath: ..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -50,6 +50,17 @@ msgstr "El código corto \"%s\" proporcionado no existe" msgid "Provided short code \"%s\" has an invalid format" msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido" +msgid "" +"You have to provide both 'oldName' and 'newName' params in order to properly " +"rename the tag" +msgstr "" +"Debes proporcionar tanto el parámetro 'oldName' como 'newName' para poder " +"renombrar la etiqueta correctamente" + +#, php-format +msgid "It wasn't possible to find a tag with name '%s'" +msgstr "No fue posible encontrar una etiqueta con el nombre '%s'" + #, php-format msgid "You need to provide the Bearer type in the %s header." msgstr "Debes proporcionar el typo Bearer en la cabecera %s." diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index c71c0176..01b8c13d 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -3,13 +3,9 @@ namespace Shlinkio\Shlink\Rest\Action; use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\StatusCodeInterface; -use Interop\Http\ServerMiddleware\DelegateInterface; use Interop\Http\ServerMiddleware\MiddlewareInterface; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Zend\Diactoros\Response\EmptyResponse; abstract class AbstractRestAction implements MiddlewareInterface, RequestMethodInterface, StatusCodeInterface { @@ -22,29 +18,4 @@ abstract class AbstractRestAction implements MiddlewareInterface, RequestMethodI { $this->logger = $logger ?: new NullLogger(); } - - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * @param Request $request - * @param DelegateInterface $delegate - * - * @return Response - */ - public function process(Request $request, DelegateInterface $delegate) - { - if ($request->getMethod() === self::METHOD_OPTIONS) { - return new EmptyResponse(); - } - - return $this->dispatch($request, $delegate); - } - - /** - * @param Request $request - * @param DelegateInterface $delegate - * @return null|Response - */ - abstract protected function dispatch(Request $request, DelegateInterface $delegate); } diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 27d67bc3..a4b15920 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -54,8 +54,9 @@ class AuthenticateAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - public function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { $authData = $request->getParsedBody(); if (! isset($authData['apiKey'])) { diff --git a/module/Rest/src/Action/CreateShortcodeAction.php b/module/Rest/src/Action/CreateShortcodeAction.php index e63db63a..3cea7469 100644 --- a/module/Rest/src/Action/CreateShortcodeAction.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -55,8 +55,9 @@ class CreateShortcodeAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - public function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { $postData = $request->getParsedBody(); if (! isset($postData['longUrl'])) { diff --git a/module/Rest/src/Action/EditTagsAction.php b/module/Rest/src/Action/EditShortcodeTagsAction.php similarity index 91% rename from module/Rest/src/Action/EditTagsAction.php rename to module/Rest/src/Action/EditShortcodeTagsAction.php index a61e940a..19f3a29d 100644 --- a/module/Rest/src/Action/EditTagsAction.php +++ b/module/Rest/src/Action/EditShortcodeTagsAction.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; use Zend\I18n\Translator\TranslatorInterface; -class EditTagsAction extends AbstractRestAction +class EditShortcodeTagsAction extends AbstractRestAction { /** * @var ShortUrlServiceInterface @@ -25,7 +25,7 @@ class EditTagsAction extends AbstractRestAction private $translator; /** - * EditTagsAction constructor. + * EditShortcodeTagsAction constructor. * @param ShortUrlServiceInterface $shortUrlService * @param TranslatorInterface $translator * @param LoggerInterface|null $logger @@ -46,8 +46,9 @@ class EditTagsAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - protected function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { $shortCode = $request->getAttribute('shortCode'); $bodyParams = $request->getParsedBody(); diff --git a/module/Rest/src/Action/GetVisitsAction.php b/module/Rest/src/Action/GetVisitsAction.php index c5e6b71b..0a03436c 100644 --- a/module/Rest/src/Action/GetVisitsAction.php +++ b/module/Rest/src/Action/GetVisitsAction.php @@ -47,8 +47,9 @@ class GetVisitsAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - public function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { $shortCode = $request->getAttribute('shortCode'); $startDate = $this->getDateQueryParam($request, 'startDate'); diff --git a/module/Rest/src/Action/ListShortcodesAction.php b/module/Rest/src/Action/ListShortcodesAction.php index b7099c1c..f4bf420b 100644 --- a/module/Rest/src/Action/ListShortcodesAction.php +++ b/module/Rest/src/Action/ListShortcodesAction.php @@ -6,7 +6,6 @@ use Interop\Http\ServerMiddleware\DelegateInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -49,8 +48,9 @@ class ListShortcodesAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - public function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { try { $params = $this->queryToListParams($request->getQueryParams()); @@ -67,7 +67,7 @@ class ListShortcodesAction extends AbstractRestAction /** * @param array $query - * @return string + * @return array */ public function queryToListParams(array $query) { diff --git a/module/Rest/src/Action/ResolveUrlAction.php b/module/Rest/src/Action/ResolveUrlAction.php index b705814a..f8167b16 100644 --- a/module/Rest/src/Action/ResolveUrlAction.php +++ b/module/Rest/src/Action/ResolveUrlAction.php @@ -46,14 +46,15 @@ class ResolveUrlAction extends AbstractRestAction * @param Request $request * @param DelegateInterface $delegate * @return null|Response + * @throws \InvalidArgumentException */ - public function dispatch(Request $request, DelegateInterface $delegate) + public function process(Request $request, DelegateInterface $delegate) { $shortCode = $request->getAttribute('shortCode'); try { $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); - if (! isset($longUrl)) { + if ($longUrl === null) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php new file mode 100644 index 00000000..5496513d --- /dev/null +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -0,0 +1,55 @@ +tagService = $tagService; + } + + /** + * 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) + { + $body = $request->getParsedBody(); + $tags = isset($body['tags']) ? $body['tags'] : []; + + return new JsonResponse([ + 'tags' => [ + 'data' => $this->tagService->createTags($tags)->toArray(), + ], + ]); + } +} diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php new file mode 100644 index 00000000..fe41bdce --- /dev/null +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -0,0 +1,51 @@ +tagService = $tagService; + } + + /** + * 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 + */ + public function process(ServerRequestInterface $request, DelegateInterface $delegate) + { + $query = $request->getQueryParams(); + $tags = isset($query['tags']) ? $query['tags'] : []; + + $this->tagService->deleteTags($tags); + return new EmptyResponse(); + } +} diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php new file mode 100644 index 00000000..acfa0846 --- /dev/null +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -0,0 +1,52 @@ +tagService = $tagService; + } + + /** + * 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) + { + return new JsonResponse([ + 'tags' => [ + 'data' => $this->tagService->listTags(), + ], + ]); + } +} diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php new file mode 100644 index 00000000..40595691 --- /dev/null +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -0,0 +1,83 @@ +tagService = $tagService; + $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) + { + $body = $request->getParsedBody(); + if (! isset($body['oldName'], $body['newName'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => $this->translator->translate( + 'You have to provide both \'oldName\' and \'newName\' params in order to properly rename the tag' + ), + ], self::STATUS_BAD_REQUEST); + } + + try { + $this->tagService->renameTag($body['oldName'], $body['newName']); + return new EmptyResponse(); + } catch (EntityDoesNotExistException $e) { + return new JsonResponse([ + 'error' => RestUtils::NOT_FOUND_ERROR, + 'message' => sprintf( + $this->translator->translate('It wasn\'t possible to find a tag with name \'%s\''), + $body['oldName'] + ), + ], self::STATUS_NOT_FOUND); + } + } +} diff --git a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php index 0304a408..d7d922ad 100644 --- a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Shlinkio\Shlink\Rest\Action\AuthenticateAction; use Shlinkio\Shlink\Rest\Authentication\JWTService; use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface; use Shlinkio\Shlink\Rest\Exception\AuthenticationException; @@ -69,7 +70,7 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface, StatusCodeIn $routeResult = $request->getAttribute(RouteResult::class); if (! isset($routeResult) || $routeResult->isFailure() - || $routeResult->getMatchedRouteName() === 'rest-authenticate' + || $routeResult->getMatchedRouteName() === AuthenticateAction::class || $request->getMethod() === 'OPTIONS' ) { return $delegate->process($request); diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index 0ce57e4f..213a3541 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -1,12 +1,13 @@ withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin')) ->withHeader('Access-Control-Expose-Headers', 'Authorization'); - if ($request->getMethod() !== 'OPTIONS') { + if ($request->getMethod() !== self::METHOD_OPTIONS) { return $response; } // Add OPTIONS-specific headers foreach ([ - 'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be based on path + 'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be dynamic +// 'Access-Control-Allow-Methods' => $response->getHeaderLine('Allow'), 'Access-Control-Max-Age' => '1000', 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), ] as $key => $value) { diff --git a/module/Rest/test/Action/EditTagsActionTest.php b/module/Rest/test/Action/EditShortcodeTagsActionTest.php similarity index 89% rename from module/Rest/test/Action/EditTagsActionTest.php rename to module/Rest/test/Action/EditShortcodeTagsActionTest.php index 518925ab..72f1dee9 100644 --- a/module/Rest/test/Action/EditTagsActionTest.php +++ b/module/Rest/test/Action/EditShortcodeTagsActionTest.php @@ -6,15 +6,15 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Service\ShortUrlService; -use Shlinkio\Shlink\Rest\Action\EditTagsAction; +use Shlinkio\Shlink\Rest\Action\EditShortcodeTagsAction; use ShlinkioTest\Shlink\Common\Util\TestUtils; use Zend\Diactoros\ServerRequestFactory; use Zend\I18n\Translator\Translator; -class EditTagsActionTest extends TestCase +class EditShortcodeTagsActionTest extends TestCase { /** - * @var EditTagsAction + * @var EditShortcodeTagsAction */ protected $action; /** @@ -25,7 +25,7 @@ class EditTagsActionTest extends TestCase public function setUp() { $this->shortUrlService = $this->prophesize(ShortUrlService::class); - $this->action = new EditTagsAction($this->shortUrlService->reveal(), Translator::factory([])); + $this->action = new EditShortcodeTagsAction($this->shortUrlService->reveal(), Translator::factory([])); } /** diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php new file mode 100644 index 00000000..22592e68 --- /dev/null +++ b/module/Rest/test/Action/Tag/CreateTagsActionTest.php @@ -0,0 +1,56 @@ +tagService = $this->prophesize(TagServiceInterface::class); + $this->action = new CreateTagsAction($this->tagService->reveal()); + } + + /** + * @test + * @dataProvider provideTags + * @param array|null $tags + */ + public function processDelegatesIntoService($tags) + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody(['tags' => $tags]); + /** @var MethodProphecy $deleteTags */ + $deleteTags = $this->tagService->createTags($tags ?: [])->willReturn(new ArrayCollection()); + + $response = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(200, $response->getStatusCode()); + $deleteTags->shouldHaveBeenCalled(); + } + + public function provideTags() + { + return [ + [['foo', 'bar', 'baz']], + [['some', 'thing']], + [null], + [[]], + ]; + } +} diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php new file mode 100644 index 00000000..26c92484 --- /dev/null +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -0,0 +1,55 @@ +tagService = $this->prophesize(TagServiceInterface::class); + $this->action = new DeleteTagsAction($this->tagService->reveal()); + } + + /** + * @test + * @dataProvider provideTags + * @param array|null $tags + */ + public function processDelegatesIntoService($tags) + { + $request = ServerRequestFactory::fromGlobals()->withQueryParams(['tags' => $tags]); + /** @var MethodProphecy $deleteTags */ + $deleteTags = $this->tagService->deleteTags($tags ?: []); + + $response = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(204, $response->getStatusCode()); + $deleteTags->shouldHaveBeenCalled(); + } + + public function provideTags() + { + return [ + [['foo', 'bar', 'baz']], + [['some', 'thing']], + [null], + [[]], + ]; + } +} diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php new file mode 100644 index 00000000..d03791c3 --- /dev/null +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -0,0 +1,50 @@ +tagService = $this->prophesize(TagServiceInterface::class); + $this->action = new ListTagsAction($this->tagService->reveal()); + } + + /** + * @test + */ + public function returnsDataFromService() + { + /** @var MethodProphecy $listTags */ + $listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]); + + $resp = $this->action->process( + ServerRequestFactory::fromGlobals(), + $this->prophesize(DelegateInterface::class)->reveal() + ); + + $this->assertEquals([ + 'tags' => [ + 'data' => ['foo', 'bar'], + ], + ], \json_decode((string) $resp->getBody(), true)); + $listTags->shouldHaveBeenCalled(); + } +} diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php new file mode 100644 index 00000000..52b2274b --- /dev/null +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -0,0 +1,89 @@ +tagService = $this->prophesize(TagServiceInterface::class); + $this->action = new UpdateTagAction($this->tagService->reveal(), Translator::factory([])); + } + + /** + * @test + * @dataProvider provideParams + * @param array $bodyParams + */ + public function whenInvalidParamsAreProvidedAnErrorIsReturned(array $bodyParams) + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody($bodyParams); + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(400, $resp->getStatusCode()); + } + + public function provideParams() + { + return [ + [['oldName' => 'foo']], + [['newName' => 'foo']], + [[]], + ]; + } + + /** + * @test + */ + public function requestingInvalidTagReturnsError() + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'oldName' => 'foo', + 'newName' => 'bar', + ]); + /** @var MethodProphecy $rename */ + $rename = $this->tagService->renameTag('foo', 'bar')->willThrow(EntityDoesNotExistException::class); + + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(404, $resp->getStatusCode()); + $rename->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function correctInvocationRenamesTag() + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'oldName' => 'foo', + 'newName' => 'bar', + ]); + /** @var MethodProphecy $rename */ + $rename = $this->tagService->renameTag('foo', 'bar')->willReturn(new Tag()); + + $resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal()); + + $this->assertEquals(204, $resp->getStatusCode()); + $rename->shouldHaveBeenCalled(); + } +} diff --git a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php index 65523f62..dcb905fe 100644 --- a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php @@ -5,6 +5,7 @@ use Interop\Http\ServerMiddleware\DelegateInterface; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Rest\Action\AuthenticateAction; use Shlinkio\Shlink\Rest\Authentication\JWTService; use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware; use ShlinkioTest\Shlink\Common\Util\TestUtils; @@ -56,7 +57,7 @@ class CheckAuthenticationMiddlewareTest extends TestCase $request = ServerRequestFactory::fromGlobals()->withAttribute( RouteResult::class, - RouteResult::fromRoute(new Route('foo', '', Route::HTTP_METHOD_ANY, 'rest-authenticate'), []) + RouteResult::fromRoute(new Route('foo', '', Route::HTTP_METHOD_ANY, AuthenticateAction::class)) ); $delegate = $this->prophesize(DelegateInterface::class); /** @var MethodProphecy $process */