diff --git a/.env.dist b/.env.dist index d6271d57..9b175618 100644 --- a/.env.dist +++ b/.env.dist @@ -4,6 +4,10 @@ SHORTENED_URL_SCHEMA= SHORTENED_URL_HOSTNAME= SHORTCODE_CHARS= +# Language +DEFAULT_LOCALE= +CLI_LOCALE= + # Database DB_USER= DB_PASSWORD= diff --git a/.gitignore b/.gitignore index 5aedee0f..9695be68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea build composer.lock vendor/ diff --git a/.travis.yml b/.travis.yml index eb2ead28..52312334 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ branches: php: - 5.6 - 7 - - hhvm + - 7.1 before_script: - composer self-update diff --git a/CHANGELOG.md b/CHANGELOG.md index c08baeb7..5c00b4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ ## CHANGELOG +### 1.0.0 + +**Enhancements:** + +* [33: Create a command to generate a short code charset by randomizing the default one](https://github.com/acelaya/url-shortener/issues/33) +* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/acelaya/url-shortener/issues/15) +* [23: Translate application literals](https://github.com/acelaya/url-shortener/issues/23) +* [21: Allow to filter visits by date range](https://github.com/acelaya/url-shortener/issues/21) +* [22: Save visits locations data on a visit_locations table](https://github.com/acelaya/url-shortener/issues/22) +* [20: Inject cross domain headers in response only if the Origin header is present in the request](https://github.com/acelaya/url-shortener/issues/20) +* [11: Separate code into multiple modules](https://github.com/acelaya/url-shortener/issues/11) +* [18: Group routable middleware in an Action namespace](https://github.com/acelaya/url-shortener/issues/18) + +**Tasks** + +* [36: Remove hhvm from the CI matrix since it doesn't support array constants and will fail](https://github.com/acelaya/url-shortener/issues/36) +* [4: Installation steps](https://github.com/acelaya/url-shortener/issues/4) +* [6: Remove dependency on expressive helpers package](https://github.com/acelaya/url-shortener/issues/6) +* [30: Replace the "services" first level config entry by "dependencies", in order to fulfill default Expressive name](https://github.com/acelaya/url-shortener/issues/30) +* [12: Improve code coverage](https://github.com/acelaya/url-shortener/issues/12) +* [25: Replace "Middleware" suffix on routable middlewares by "Action"](https://github.com/acelaya/url-shortener/issues/25) +* [19: Update the vendor and app namespace from Acelaya\UrlShortener to Shlinkio\Shlink](https://github.com/acelaya/url-shortener/issues/19) + +**Bugs** + +* [24: Prevent duplicated shortcodes errors because of the case insensitive behavior on MySQL](https://github.com/acelaya/url-shortener/issues/24) + ### 0.2.0 **Enhancements:** diff --git a/README.md b/README.md index dafb7c27..5a605704 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ -# url-shortener +# Shlink + +[![Build Status](https://travis-ci.org/shlinkio/shlink.svg?branch=master)](https://travis-ci.org/shlinkio/shlink) +[![Code Coverage](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master) +[![Latest Stable Version](https://poser.pugx.org/shlinkio/shlink/v/stable.png)](https://packagist.org/packages/shlinkio/shlink) +[![License](https://poser.pugx.org/shlinkio/shlink/license.png)](https://packagist.org/packages/shlinkio/shlink) + A PHP-based URL shortener application with analytics and management diff --git a/bin/cli b/bin/cli index e400bef8..abbeed47 100755 --- a/bin/cli +++ b/bin/cli @@ -2,9 +2,16 @@ get('translator'); +$translator->setLocale(env('CLI_LOCALE', 'en')); + +/** @var Application $app */ $app = $container->get(CliApp::class); $app->run(); diff --git a/composer.json b/composer.json index 374fed26..829bb949 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,12 @@ { - "name": "acelaya/url-shortener", + "name": "shlinkio/shlink", "type": "project", - "homepage": "https://github.com/acelaya/url-shortener", + "homepage": "http://shlink.io", + "description": "A self-hosted and PHP-based URL shortener application with CLI and REST interfaces", "license": "MIT", "authors": [ { - "name": "Alejandro Celaya ALastrué", + "name": "Alejandro Celaya Alastrué", "homepage": "http://www.alejandrocelaya.com", "email": "alejandro@alejandrocelaya.com" } @@ -13,15 +14,17 @@ "require": { "php": "^5.6 || ^7.0", "zendframework/zend-expressive": "^1.0", - "zendframework/zend-expressive-helpers": "^2.0", "zendframework/zend-expressive-fastroute": "^1.1", "zendframework/zend-expressive-twigrenderer": "^1.0", "zendframework/zend-stdlib": "^2.7", "zendframework/zend-servicemanager": "^3.0", "zendframework/zend-paginator": "^2.6", + "zendframework/zend-config": "^2.6", + "zendframework/zend-i18n": "^2.7", + "mtymek/expressive-config-manager": "^0.4", + "acelaya/zsm-annotated-services": "^0.2.0", "doctrine/orm": "^2.5", "guzzlehttp/guzzle": "^6.2", - "acelaya/zsm-annotated-services": "^0.2.0", "symfony/console": "^3.0" }, "require-dev": { @@ -34,12 +37,21 @@ }, "autoload": { "psr-4": { - "Acelaya\\UrlShortener\\": "src" - } + "Shlinkio\\Shlink\\CLI\\": "module/CLI/src", + "Shlinkio\\Shlink\\Rest\\": "module/Rest/src", + "Shlinkio\\Shlink\\Core\\": "module/Core/src", + "Shlinkio\\Shlink\\Common\\": "module/Common/src" + }, + "files": [ + "module/Common/functions/functions.php" + ] }, "autoload-dev": { "psr-4": { - "AcelayaTest\\UrlShortener\\": "tests" + "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", + "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", + "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", + "ShlinkioTest\\Shlink\\Common\\": "module/Common/test" } }, "scripts": { diff --git a/config/autoload/database.global.php b/config/autoload/database.global.php index 4e3b00e3..4d05ea36 100644 --- a/config/autoload/database.global.php +++ b/config/autoload/database.global.php @@ -3,9 +3,9 @@ return [ 'database' => [ 'driver' => 'pdo_mysql', - 'user' => getenv('DB_USER'), - 'password' => getenv('DB_PASSWORD'), - 'dbname' => getenv('DB_NAME') ?: 'acelaya_url_shortener', + 'user' => env('DB_USER'), + 'password' => env('DB_PASSWORD'), + 'dbname' => env('DB_NAME', 'shlink'), 'charset' => 'utf8', 'driverOptions' => [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8' diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php new file mode 100644 index 00000000..209334b2 --- /dev/null +++ b/config/autoload/dependencies.global.php @@ -0,0 +1,24 @@ + [ + 'factories' => [ + Expressive\Application::class => Container\ApplicationFactory::class, + Router\FastRouteRouter::class => InvokableFactory::class, + Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class, + ], + 'aliases' => [ + Router\RouterInterface::class => Router\FastRouteRouter::class, + 'Zend\Expressive\FinalHandler' => ContentBasedErrorHandler::class, + ], + ], + +]; diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist index 92a92497..7e361e0a 100644 --- a/config/autoload/errorhandler.local.php.dist +++ b/config/autoload/errorhandler.local.php.dist @@ -1,14 +1,13 @@ [ + 'dependencies' => [ 'invokables' => [ 'Zend\Expressive\Whoops' => Whoops\Run::class, 'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class, ], - 'factories' => [ - 'Zend\Expressive\FinalHandler' => Zend\Expressive\Container\WhoopsErrorHandlerFactory::class, - ], ], 'whoops' => [ @@ -18,4 +17,12 @@ return [ 'ajax_only' => true, ], ], + + 'error_handler' => [ + 'plugins' => [ + 'factories' => [ + ContentBasedErrorHandler::DEFAULT_CONTENT => WhoopsErrorHandlerFactory::class, + ], + ], + ], ]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index fc6f85f0..0e214307 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -1,18 +1,9 @@ [ - 'always' => [ - 'middleware' => [ - Helper\ServerUrlMiddleware::class, - ], - 'priority' => 10000, - ], - 'routing' => [ 'middleware' => [ ApplicationFactory::ROUTING_MIDDLEWARE, @@ -20,27 +11,11 @@ return [ 'priority' => 10, ], - 'rest' => [ - 'path' => '/rest', - 'middleware' => [ - Middleware\CheckAuthenticationMiddleware::class, - Middleware\CrossDomainMiddleware::class, - ], - 'priority' => 5, - ], - 'post-routing' => [ 'middleware' => [ - Helper\UrlHelperMiddleware::class, ApplicationFactory::DISPATCH_MIDDLEWARE, ], 'priority' => 1, ], - - 'error' => [ - 'middleware' => [], - 'error' => true, - 'priority' => -10000, - ], ], ]; diff --git a/config/autoload/rest.global.php b/config/autoload/rest.global.php deleted file mode 100644 index 6e3fc216..00000000 --- a/config/autoload/rest.global.php +++ /dev/null @@ -1,9 +0,0 @@ - [ - 'username' => getenv('REST_USER'), - 'password' => getenv('REST_PASSWORD'), - ], - -]; diff --git a/config/autoload/services.global.php b/config/autoload/services.global.php deleted file mode 100644 index 4d6cc40e..00000000 --- a/config/autoload/services.global.php +++ /dev/null @@ -1,70 +0,0 @@ - [ - 'factories' => [ - Expressive\Application::class => Container\ApplicationFactory::class, - Console\Application::class => CLI\Factory\ApplicationFactory::class, - - // Url helpers - Helper\UrlHelper::class => Helper\UrlHelperFactory::class, - Helper\ServerUrlMiddleware::class => Helper\ServerUrlMiddlewareFactory::class, - Helper\UrlHelperMiddleware::class => Helper\UrlHelperMiddlewareFactory::class, - Helper\ServerUrlHelper::class => InvokableFactory::class, - Router\FastRouteRouter::class => InvokableFactory::class, - - // View - 'Zend\Expressive\FinalHandler' => Container\TemplatedErrorHandlerFactory::class, - Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class, - - // Services - EntityManager::class => EntityManagerFactory::class, - GuzzleHttp\Client::class => InvokableFactory::class, - Service\UrlShortener::class => AnnotatedFactory::class, - Service\VisitsTracker::class => AnnotatedFactory::class, - Service\ShortUrlService::class => AnnotatedFactory::class, - Service\RestTokenService::class => AnnotatedFactory::class, - Cache::class => CacheFactory::class, - - // Cli commands - CLI\Command\GenerateShortcodeCommand::class => AnnotatedFactory::class, - CLI\Command\ResolveUrlCommand::class => AnnotatedFactory::class, - CLI\Command\ListShortcodesCommand::class => AnnotatedFactory::class, - CLI\Command\GetVisitsCommand::class => AnnotatedFactory::class, - - // Middleware - Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class, - Middleware\Rest\AuthenticateMiddleware::class => AnnotatedFactory::class, - Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class, - Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class, - Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class, - Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class, - Middleware\CrossDomainMiddleware::class => InvokableFactory::class, - Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, - ], - 'aliases' => [ - 'em' => EntityManager::class, - 'httpClient' => GuzzleHttp\Client::class, - Router\RouterInterface::class => Router\FastRouteRouter::class, - AnnotatedFactory::CACHE_SERVICE => Cache::class, - ] - ], - -]; diff --git a/config/autoload/templates.global.php b/config/autoload/templates.global.php index 2beb9918..f102baa0 100644 --- a/config/autoload/templates.global.php +++ b/config/autoload/templates.global.php @@ -2,12 +2,6 @@ return [ - 'templates' => [ - 'paths' => [ - 'templates' - ], - ], - 'twig' => [ 'cache_dir' => 'data/cache/twig', 'extensions' => [ diff --git a/config/autoload/translator.global.php b/config/autoload/translator.global.php new file mode 100644 index 00000000..d0561cbe --- /dev/null +++ b/config/autoload/translator.global.php @@ -0,0 +1,8 @@ + [ + 'locale' => env('DEFAULT_LOCALE', 'en'), + ], + +]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 718ed3f0..5b0d95c9 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -1,12 +1,14 @@ [ 'domain' => [ - 'schema' => getenv('SHORTENED_URL_SCHEMA') ?: 'http', - 'hostname' => getenv('SHORTENED_URL_HOSTNAME'), + 'schema' => env('SHORTENED_URL_SCHEMA', 'http'), + 'hostname' => env('SHORTENED_URL_HOSTNAME'), ], - 'shortcode_chars' => getenv('SHORTCODE_CHARS'), + 'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS), ], ]; diff --git a/config/autoload/zend-expressive.global.php b/config/autoload/zend-expressive.global.php index db5e3f38..aa2e9d3b 100644 --- a/config/autoload/zend-expressive.global.php +++ b/config/autoload/zend-expressive.global.php @@ -1,14 +1,8 @@ false, + 'config_cache_enabled' => true, - 'config_cache_enabled' => false, - - 'zend-expressive' => [ - 'error_handler' => [ - 'template_404' => 'error/404.html.twig', - 'template_error' => 'error/error.html.twig', - ], - ], ]; diff --git a/cli-config.php b/config/cli-config.php similarity index 83% rename from cli-config.php rename to config/cli-config.php index be9b8b82..30e31a93 100644 --- a/cli-config.php +++ b/config/cli-config.php @@ -4,7 +4,7 @@ use Doctrine\ORM\Tools\Console\ConsoleRunner; use Interop\Container\ContainerInterface; /** @var ContainerInterface $container */ -$container = include __DIR__ . '/config/container.php'; +$container = include __DIR__ . '/container.php'; /** @var EntityManager $em */ $em = $container->get(EntityManager::class); diff --git a/config/config.php b/config/config.php index a3d0e7ac..f5010bd0 100644 --- a/config/config.php +++ b/config/config.php @@ -1,6 +1,10 @@ getMergedConfig(); diff --git a/config/container.php b/config/container.php index 5678edcb..cbc84db5 100644 --- a/config/container.php +++ b/config/container.php @@ -16,6 +16,6 @@ if (class_exists(Dotenv::class)) { // Build container $config = require __DIR__ . '/config.php'; -$container = new ServiceManager($config['services']); +$container = new ServiceManager($config['dependencies']); $container->setService('config', $config); return $container; diff --git a/data/docs/installation.md b/data/docs/installation.md new file mode 100644 index 00000000..67e241fc --- /dev/null +++ b/data/docs/installation.md @@ -0,0 +1,19 @@ +### Installation steps + +- Define ENV vars in apache or nginx: + - SHORTENED_URL_SCHEMA: http|https + - SHORTENED_URL_HOSTNAME: Short domain + - SHORTCODE_CHARS: The char set used to generate short codes (defaults to **123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ**, but a new one can be generated with the `config:generate-charset` command) + - DB_USER: MySQL database user + - DB_PASSWORD: MySQL database password + - REST_USER: Username for REST authentication + - REST_PASSWORD: Password for REST authentication + - DB_NAME: MySQL database name (defaults to **shlink**) + - DEFAULT_LOCALE: Language in which web requests (browser and REST) will be returned if no `Accept-Language` header is sent (defaults to **en**) + - CLI_LOCALE: Language in which console command messages will be displayed (defaults to **en**) +- Create database (`vendor/bin/doctrine orm:schema-tool:create`) +- Add write permissions to `data` directory +- Create doctrine proxies (`vendor/bin/doctrine orm:generate-proxies`) +- Create symlink to bin/cli as `shlink` in /usr/local/bin (linux only. Optional) + +Supported languages: es and en diff --git a/data/docs/rest.md b/data/docs/rest.md index f93f6c48..bbc9a565 100644 --- a/data/docs/rest.md +++ b/data/docs/rest.md @@ -13,7 +13,15 @@ Statuses: ## Authentication -[TODO] +Once you have called to the authentication endpoint for the first time (see below) yopu will get an authentication token. + +You will have to send that token in the `X-Auth-Token` header on any later request or you will get an authentication error. + +## Language + +In order to set the application language, you have to pass it by using the `Accept-Language` header. + +If not provided or provided language is not supported, english (en_US) will be used. ## Endpoints @@ -222,9 +230,12 @@ Posible errors: **REQUEST** -* `GET` -> `/rest/visits/{shortCode}` +* `GET` -> `/rest/short-codes/{shortCode}/visits` * Route params: * shortCode: `string` -> The shortCode from which we eant to get the visits. +* Query params: + * startDate: `string` -> If provided, only visits older that this date will be returned + * endDate: `string` -> If provided, only visits newer that this date will be returned * Headers: * X-Auth-Token: `string` -> The token provided in the authentication request diff --git a/config/autoload/cli.global.php b/module/CLI/config/cli.config.php similarity index 65% rename from config/autoload/cli.global.php rename to module/CLI/config/cli.config.php index 1276cc9e..a9b13a72 100644 --- a/config/autoload/cli.global.php +++ b/module/CLI/config/cli.config.php @@ -1,5 +1,5 @@ [ + 'factories' => [ + Application::class => ApplicationFactory::class, + + Command\GenerateShortcodeCommand::class => AnnotatedFactory::class, + Command\ResolveUrlCommand::class => AnnotatedFactory::class, + Command\ListShortcodesCommand::class => AnnotatedFactory::class, + Command\GetVisitsCommand::class => AnnotatedFactory::class, + Command\ProcessVisitsCommand::class => AnnotatedFactory::class, + Command\ProcessVisitsCommand::class => AnnotatedFactory::class, + Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class, + ], + ], + +]; diff --git a/module/CLI/config/translator.config.php b/module/CLI/config/translator.config.php new file mode 100644 index 00000000..ae120db3 --- /dev/null +++ b/module/CLI/config/translator.config.php @@ -0,0 +1,14 @@ + [ + 'translation_file_patterns' => [ + [ + 'type' => 'gettext', + 'base_dir' => __DIR__ . '/../lang', + 'pattern' => '%s.mo', + ], + ], + ], + +]; diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo new file mode 100644 index 00000000..b51c40cb Binary files /dev/null and b/module/CLI/lang/es.mo differ diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po new file mode 100644 index 00000000..8bc5d32b --- /dev/null +++ b/module/CLI/lang/es.po @@ -0,0 +1,147 @@ +msgid "" +msgstr "" +"Project-Id-Version: Shlink 1.0\n" +"POT-Creation-Date: 2016-08-01 21:21+0200\n" +"PO-Revision-Date: 2016-08-01 21:22+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-Poedit-Basepath: ..\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-KeywordsList: translate;translatePlural\n" +"X-Poedit-SearchPath-0: src\n" +"X-Poedit-SearchPath-1: config\n" + +#, php-format +msgid "" +"Generates a character set sample just by shuffling the default one, \"%s\". " +"Then it can be set in the SHORTCODE_CHARS environment variable" +msgstr "" +"Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s" +"\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS" + +msgid "Character set:" +msgstr "Grupo de caracteres:" + +#, fuzzy +msgid "Generates a short code for provided URL and returns the short URL" +msgstr "" +"Genera un código corto para la URL proporcionada y devuelve la URL acortada" + +msgid "The long URL to parse" +msgstr "La URL larga a procesar" + +msgid "A long URL was not provided. Which URL do you want to shorten?:" +msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" + +msgid "A URL was not provided!" +msgstr "¡No se ha proporcionado una URL!" + +msgid "Processed URL:" +msgstr "URL procesada:" + +msgid "Generated URL:" +msgstr "URL generada:" + +#, php-format +msgid "Provided URL \"%s\" is invalid. Try with a different one." +msgstr "La URL proporcionada \"%s\" e inválida. Prueba con una diferente." + +msgid "Returns the detailed visits information for provided short code" +msgstr "" +"Devuelve la información detallada de visitas para el código corto " +"proporcionado" + +msgid "The short code which visits we want to get" +msgstr "El código corto del cual queremos obtener las visitas" + +msgid "Allows to filter visits, returning only those older than start date" +msgstr "" +"Permite filtrar las visitas, devolviendo sólo aquellas más antiguas que " +"startDate" + +msgid "Allows to filter visits, returning only those newer than end date" +msgstr "" +"Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate" + +msgid "A short code was not provided. Which short code do you want to use?:" +msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?" + +msgid "Referer" +msgstr "Origen" + +msgid "Date" +msgstr "Fecha" + +msgid "Remote Address" +msgstr "Dirección remota" + +msgid "User agent" +msgstr "Agente de usuario" + +msgid "List all short URLs" +msgstr "Listar todas las URLs cortas" + +#, php-format +msgid "The first page to list (%s items per page)" +msgstr "La primera página a listar (%s elementos por página)" + +msgid "Short code" +msgstr "Código corto" + +msgid "Original URL" +msgstr "URL original" + +msgid "Date created" +msgstr "Fecha de creación" + +msgid "Visits count" +msgstr "Número de visitas" + +msgid "You have reached last page" +msgstr "Has alcanzado la última página" + +msgid "Continue with page" +msgstr "Continuar con la página" + +msgid "Processes visits where location is not set yet" +msgstr "Procesa las visitas donde la localización no ha sido establecida aún" + +msgid "Processing IP" +msgstr "Procesando IP" + +msgid "Ignored localhost address" +msgstr "Ignorada IP de localhost" + +#, php-format +msgid "Address located at \"%s\"" +msgstr "Dirección localizada en \"%s\"" + +msgid "Finished processing all IPs" +msgstr "Finalizado el procesado de todas las IPs" + +msgid "Returns the long URL behind a short code" +msgstr "Devuelve la URL larga detrás de un código corto" + +msgid "The short code to parse" +msgstr "El código corto a convertir" + +msgid "A short code was not provided. Which short code do you want to parse?:" +msgstr "" +"No se proporcionó un código corto. ¿Qué código corto quieres convertir?" + +#, php-format +msgid "No URL found for short code \"%s\"" +msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" + +msgid "Long URL:" +msgstr "URL larga:" + +#, php-format +msgid "Provided short code \"%s\" has an invalid format." +msgstr "El código corto proporcionado \"%s\" tiene un formato inválido." diff --git a/module/CLI/src/Command/Config/GenerateCharsetCommand.php b/module/CLI/src/Command/Config/GenerateCharsetCommand.php new file mode 100644 index 00000000..22369934 --- /dev/null +++ b/module/CLI/src/Command/Config/GenerateCharsetCommand.php @@ -0,0 +1,44 @@ +translator = $translator; + parent::__construct(null); + } + + public function configure() + { + $this->setName('config:generate-charset') + ->setDescription(sprintf($this->translator->translate( + 'Generates a character set sample just by shuffling the default one, "%s". ' + . 'Then it can be set in the SHORTCODE_CHARS environment variable' + ), UrlShortener::DEFAULT_CHARS)); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $charSet = str_shuffle(UrlShortener::DEFAULT_CHARS); + $output->writeln($this->translator->translate('Character set:') . sprintf(' %s', $charSet)); + } +} diff --git a/src/CLI/Command/GenerateShortcodeCommand.php b/module/CLI/src/Command/GenerateShortcodeCommand.php similarity index 50% rename from src/CLI/Command/GenerateShortcodeCommand.php rename to module/CLI/src/Command/GenerateShortcodeCommand.php index ffc355db..f02c110b 100644 --- a/src/CLI/Command/GenerateShortcodeCommand.php +++ b/module/CLI/src/Command/GenerateShortcodeCommand.php @@ -1,18 +1,18 @@ urlShortener = $urlShortener; + $this->translator = $translator; $this->domainConfig = $domainConfig; + parent::__construct(null); } public function configure() { $this->setName('shortcode:generate') - ->setDescription('Generates a shortcode for provided URL and returns the short URL') - ->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse'); + ->setDescription( + $this->translator->translate('Generates a short code for provided URL and returns the short URL') + ) + ->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse')); } public function interact(InputInterface $input, OutputInterface $output) @@ -55,9 +66,10 @@ class GenerateShortcodeCommand extends Command /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); - $question = new Question( - 'A long URL was not provided. Which URL do you want to shorten?: ' - ); + $question = new Question(sprintf( + '%s ', + $this->translator->translate('A long URL was not provided. Which URL do you want to shorten?:') + )); $longUrl = $helper->ask($input, $output, $question); if (! empty($longUrl)) { @@ -71,23 +83,26 @@ class GenerateShortcodeCommand extends Command try { if (! isset($longUrl)) { - $output->writeln('A URL was not provided!'); + $output->writeln(sprintf('%s', $this->translator->translate('A URL was not provided!'))); return; } - $shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); - $shortUrl = (new Uri())->withPath($shortcode) + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortUrl = (new Uri())->withPath($shortCode) ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); $output->writeln([ - sprintf('Processed URL %s', $longUrl), - sprintf('Generated URL %s', $shortUrl), + sprintf('%s %s', $this->translator->translate('Processed URL:'), $longUrl), + sprintf('%s %s', $this->translator->translate('Generated URL:'), $shortUrl), ]); } catch (InvalidUrlException $e) { - $output->writeln( - sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl) - ); + $output->writeln(sprintf( + '' . $this->translator->translate( + 'Provided URL "%s" is invalid. Try with a different one.' + ) . '', + $longUrl + )); } } } diff --git a/module/CLI/src/Command/GetVisitsCommand.php b/module/CLI/src/Command/GetVisitsCommand.php new file mode 100644 index 00000000..c25ba27a --- /dev/null +++ b/module/CLI/src/Command/GetVisitsCommand.php @@ -0,0 +1,122 @@ +visitsTracker = $visitsTracker; + $this->translator = $translator; + parent::__construct(null); + } + + public function configure() + { + $this->setName('shortcode:visits') + ->setDescription( + $this->translator->translate('Returns the detailed visits information for provided short code') + ) + ->addArgument( + 'shortCode', + InputArgument::REQUIRED, + $this->translator->translate('The short code which visits we want to get') + ) + ->addOption( + 'startDate', + 's', + InputOption::VALUE_OPTIONAL, + $this->translator->translate('Allows to filter visits, returning only those older than start date') + ) + ->addOption( + 'endDate', + 'e', + InputOption::VALUE_OPTIONAL, + $this->translator->translate('Allows to filter visits, returning only those newer than end date') + ); + } + + public function interact(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + if (! empty($shortCode)) { + return; + } + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question(sprintf( + '%s ', + $this->translator->translate('A short code was not provided. Which short code do you want to use?:') + )); + + $shortCode = $helper->ask($input, $output, $question); + if (! empty($shortCode)) { + $input->setArgument('shortCode', $shortCode); + } + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $shortCode = $input->getArgument('shortCode'); + $startDate = $this->getDateOption($input, 'startDate'); + $endDate = $this->getDateOption($input, 'endDate'); + + $visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate)); + $table = new Table($output); + $table->setHeaders([ + $this->translator->translate('Referer'), + $this->translator->translate('Date'), + $this->translator->translate('Remote Address'), + $this->translator->translate('User agent'), + ]); + + foreach ($visits as $row) { + $rowData = $row->jsonSerialize(); + // Unset location info + unset($rowData['visitLocation']); + + $table->addRow(array_values($rowData)); + } + $table->render(); + } + + protected function getDateOption(InputInterface $input, $key) + { + $value = $input->getOption($key); + if (isset($value)) { + $value = new \DateTime($value); + } + + return $value; + } +} diff --git a/src/CLI/Command/ListShortcodesCommand.php b/module/CLI/src/Command/ListShortcodesCommand.php similarity index 57% rename from src/CLI/Command/ListShortcodesCommand.php rename to module/CLI/src/Command/ListShortcodesCommand.php index ac85d50f..e59aa903 100644 --- a/src/CLI/Command/ListShortcodesCommand.php +++ b/module/CLI/src/Command/ListShortcodesCommand.php @@ -1,11 +1,11 @@ shortUrlService = $shortUrlService; + $this->translator = $translator; + parent::__construct(null); } public function configure() { $this->setName('shortcode:list') - ->setDescription('List all short URLs') + ->setDescription($this->translator->translate('List all short URLs')) ->addOption( 'page', 'p', InputOption::VALUE_OPTIONAL, - sprintf('The first page to list (%s items per page)', PaginableRepositoryAdapter::ITEMS_PER_PAGE), + sprintf( + $this->translator->translate('The first page to list (%s items per page)'), + PaginableRepositoryAdapter::ITEMS_PER_PAGE + ), 1 ); } @@ -59,10 +69,10 @@ class ListShortcodesCommand extends Command $page++; $table = new Table($output); $table->setHeaders([ - 'Short code', - 'Original URL', - 'Date created', - 'Visits count', + $this->translator->translate('Short code'), + $this->translator->translate('Original URL'), + $this->translator->translate('Date created'), + $this->translator->translate('Visits count'), ]); foreach ($result as $row) { @@ -72,10 +82,14 @@ class ListShortcodesCommand extends Command if ($this->isLastPage($result)) { $continue = false; - $output->writeln('You have reached last page'); + $output->writeln( + sprintf('%s', $this->translator->translate('You have reached last page')) + ); } else { $continue = $helper->ask($input, $output, new ConfirmationQuestion( - sprintf('Continue with page %s? (y/N) ', $page), + sprintf('' . $this->translator->translate( + 'Continue with page' + ) . ' %s? (y/N) ', $page), false )); } diff --git a/module/CLI/src/Command/ProcessVisitsCommand.php b/module/CLI/src/Command/ProcessVisitsCommand.php new file mode 100644 index 00000000..e9f95a7b --- /dev/null +++ b/module/CLI/src/Command/ProcessVisitsCommand.php @@ -0,0 +1,91 @@ +visitService = $visitService; + $this->ipLocationResolver = $ipLocationResolver; + $this->translator = $translator; + parent::__construct(null); + } + + public function configure() + { + $this->setName('visit:process') + ->setDescription( + $this->translator->translate('Processes visits where location is not set yet') + ); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $visits = $this->visitService->getUnlocatedVisits(); + + foreach ($visits as $visit) { + $ipAddr = $visit->getRemoteAddr(); + $output->write(sprintf('%s %s', $this->translator->translate('Processing IP'), $ipAddr)); + if ($ipAddr === self::LOCALHOST) { + $output->writeln( + sprintf(' (%s)', $this->translator->translate('Ignored localhost address')) + ); + continue; + } + + try { + $result = $this->ipLocationResolver->resolveIpLocation($ipAddr); + $location = new VisitLocation(); + $location->exchangeArray($result); + $visit->setVisitLocation($location); + $this->visitService->saveVisit($visit); + $output->writeln(sprintf( + ' (' . $this->translator->translate('Address located at "%s"') . ')', + $location->getCityName() + )); + } catch (WrongIpException $e) { + continue; + } + } + + $output->writeln($this->translator->translate('Finished processing all IPs')); + } +} diff --git a/src/CLI/Command/ResolveUrlCommand.php b/module/CLI/src/Command/ResolveUrlCommand.php similarity index 52% rename from src/CLI/Command/ResolveUrlCommand.php rename to module/CLI/src/Command/ResolveUrlCommand.php index 4eb5ff41..62d81f41 100644 --- a/src/CLI/Command/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ResolveUrlCommand.php @@ -1,16 +1,17 @@ urlShortener = $urlShortener; + $this->translator = $translator; + parent::__construct(null); } public function configure() { $this->setName('shortcode:parse') - ->setDescription('Returns the long URL behind a short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse'); + ->setDescription($this->translator->translate('Returns the long URL behind a short code')) + ->addArgument( + 'shortCode', + InputArgument::REQUIRED, + $this->translator->translate('The short code to parse') + ); } public function interact(InputInterface $input, OutputInterface $output) @@ -47,9 +58,10 @@ class ResolveUrlCommand extends Command /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); - $question = new Question( - 'A short code was not provided. Which short code do you want to parse?: ' - ); + $question = new Question(sprintf( + '%s ', + $this->translator->translate('A short code was not provided. Which short code do you want to parse?:') + )); $shortCode = $helper->ask($input, $output, $question); if (! empty($shortCode)) { @@ -64,15 +76,18 @@ class ResolveUrlCommand extends Command try { $longUrl = $this->urlShortener->shortCodeToUrl($shortCode); if (! isset($longUrl)) { - $output->writeln(sprintf('No URL found for short code "%s"', $shortCode)); + $output->writeln(sprintf( + '' . $this->translator->translate('No URL found for short code "%s"') . '', + $shortCode + )); return; } - $output->writeln(sprintf('Long URL %s', $longUrl)); + $output->writeln(sprintf('%s %s', $this->translator->translate('Long URL:'), $longUrl)); } catch (InvalidShortCodeException $e) { - $output->writeln( - sprintf('Provided short code "%s" has an invalid format.', $shortCode) - ); + $output->writeln(sprintf('' . $this->translator->translate( + 'Provided short code "%s" has an invalid format.' + ) . '', $shortCode)); } } } diff --git a/module/CLI/src/ConfigProvider.php b/module/CLI/src/ConfigProvider.php new file mode 100644 index 00000000..a35c98b6 --- /dev/null +++ b/module/CLI/src/ConfigProvider.php @@ -0,0 +1,13 @@ +get('config')['cli']; - $app = new CliApp(); + $app = new CliApp('Shlink', '1.0.0'); $commands = isset($config['commands']) ? $config['commands'] : []; foreach ($commands as $command) { diff --git a/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php b/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php new file mode 100644 index 00000000..03a81b53 --- /dev/null +++ b/module/CLI/test/Command/Config/GenerateCharsetCommandTest.php @@ -0,0 +1,56 @@ +add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function charactersAreGeneratedFromDefault() + { + $prefix = 'Character set: '; + $prefixLength = strlen($prefix); + + $this->commandTester->execute([ + 'command' => 'config:generate-charset', + ]); + $output = $this->commandTester->getDisplay(); + + // Both default character set and the new one should have the same length + $this->assertEquals($prefixLength + strlen(UrlShortener::DEFAULT_CHARS) + 1, strlen($output)); + + // Both default character set and the new one should have the same characters + $charset = substr($output, $prefixLength, strlen(UrlShortener::DEFAULT_CHARS)); + $orderedDefault = $this->orderStringLetters(UrlShortener::DEFAULT_CHARS); + $orderedCharset = $this->orderStringLetters($charset); + $this->assertEquals($orderedDefault, $orderedCharset); + } + + protected function orderStringLetters($string) + { + $letters = str_split($string); + sort($letters); + return implode('', $letters); + } +} diff --git a/module/CLI/test/Command/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/GenerateShortcodeCommandTest.php new file mode 100644 index 00000000..45cb8130 --- /dev/null +++ b/module/CLI/test/Command/GenerateShortcodeCommandTest.php @@ -0,0 +1,70 @@ +urlShortener = $this->prophesize(UrlShortener::class); + $command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [ + 'schema' => 'http', + 'hostname' => 'foo.com' + ]); + $app = new Application(); + $app->add($command); + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function properShortCodeIsCreatedIfLongUrlIsCorrect() + { + $this->urlShortener->urlToShortCode(Argument::any())->willReturn('abc123') + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:generate', + 'longUrl' => 'http://domain.com/foo/bar' + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'http://foo.com/abc123') > 0); + } + + /** + * @test + */ + public function exceptionWhileParsingLongUrlOutputsError() + { + $this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException()) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:generate', + 'longUrl' => 'http://domain.com/invalid' + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue( + strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0 + ); + } +} diff --git a/module/CLI/test/Command/GetVisitsCommandTest.php b/module/CLI/test/Command/GetVisitsCommandTest.php new file mode 100644 index 00000000..4294823b --- /dev/null +++ b/module/CLI/test/Command/GetVisitsCommandTest.php @@ -0,0 +1,91 @@ +visitsTracker = $this->prophesize(VisitsTrackerInterface::class); + $command = new GetVisitsCommand($this->visitsTracker->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function noDateFlagsTriesToListWithoutDateRange() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([]) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:visits', + 'shortCode' => $shortCode, + ]); + } + + /** + * @test + */ + public function providingDateFlagsTheListGetsFiltered() + { + $shortCode = 'abc123'; + $startDate = '2016-01-01'; + $endDate = '2016-02-01'; + $this->visitsTracker->info($shortCode, new DateRange(new \DateTime($startDate), new \DateTime($endDate))) + ->willReturn([]) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:visits', + 'shortCode' => $shortCode, + '--startDate' => $startDate, + '--endDate' => $endDate, + ]); + } + + /** + * @test + */ + public function outputIsProperlyGenerated() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, Argument::any())->willReturn([ + (new Visit())->setReferer('foo') + ->setRemoteAddr('1.2.3.4') + ->setUserAgent('bar'), + ])->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:visits', + 'shortCode' => $shortCode, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'foo') > 0); + $this->assertTrue(strpos($output, '1.2.3.4') > 0); + $this->assertTrue(strpos($output, 'bar') > 0); + } +} diff --git a/module/CLI/test/Command/ListShortcodesCommandTest.php b/module/CLI/test/Command/ListShortcodesCommandTest.php new file mode 100644 index 00000000..7ec0e5af --- /dev/null +++ b/module/CLI/test/Command/ListShortcodesCommandTest.php @@ -0,0 +1,119 @@ +shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); + $app = new Application(); + $command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([])); + $app->add($command); + + $this->questionHelper = $command->getHelper('question'); + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function noInputCallsListJustOnce() + { + $this->questionHelper->setInputStream($this->getInputStream('\n')); + $this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute(['command' => 'shortcode:list']); + } + + /** + * @test + */ + public function loadingMorePagesCallsListMoreTimes() + { + // The paginator will return more than one page for the first 3 times + $data = []; + for ($i = 0; $i < 30; $i++) { + $data[] = new ShortUrl(); + } + $data = array_chunk($data, 11); + + $questionHelper = $this->questionHelper; + $that = $this; + $this->shortUrlService->listShortUrls(Argument::any())->will(function () use (&$data, $questionHelper, $that) { + $questionHelper->setInputStream($that->getInputStream('y')); + return new Paginator(new ArrayAdapter(array_shift($data))); + })->shouldBeCalledTimes(3); + + $this->commandTester->execute(['command' => 'shortcode:list']); + } + + /** + * @test + */ + public function havingMorePagesButAnsweringNoCallsListJustOnce() + { + // The paginator will return more than one page + $data = []; + for ($i = 0; $i < 30; $i++) { + $data[] = new ShortUrl(); + } + + $this->questionHelper->setInputStream($this->getInputStream('n')); + $this->shortUrlService->listShortUrls(Argument::any())->willReturn(new Paginator(new ArrayAdapter($data))) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute(['command' => 'shortcode:list']); + } + + /** + * @test + */ + public function passingPageWillMakeListStartOnThatPage() + { + $page = 5; + $this->questionHelper->setInputStream($this->getInputStream('\n')); + $this->shortUrlService->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:list', + '--page' => $page, + ]); + } + + protected function getInputStream($inputData) + { + $stream = fopen('php://memory', 'r+', false); + fputs($stream, $inputData); + rewind($stream); + + return $stream; + } +} diff --git a/module/CLI/test/Command/ProcessVisitsCommandTest.php b/module/CLI/test/Command/ProcessVisitsCommandTest.php new file mode 100644 index 00000000..23463123 --- /dev/null +++ b/module/CLI/test/Command/ProcessVisitsCommandTest.php @@ -0,0 +1,96 @@ +visitService = $this->prophesize(VisitService::class); + $this->ipResolver = $this->prophesize(IpLocationResolver::class); + $command = new ProcessVisitsCommand( + $this->visitService->reveal(), + $this->ipResolver->reveal(), + Translator::factory([]) + ); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function allReturnedVisitsIpsAreProcessed() + { + $visits = [ + (new Visit())->setRemoteAddr('1.2.3.4'), + (new Visit())->setRemoteAddr('4.3.2.1'), + (new Visit())->setRemoteAddr('12.34.56.78'), + ]; + $this->visitService->getUnlocatedVisits()->willReturn($visits) + ->shouldBeCalledTimes(1); + + $this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits)); + $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]) + ->shouldBeCalledTimes(count($visits)); + + $this->commandTester->execute([ + 'command' => 'visit:process', + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0); + $this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0); + $this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0); + } + + /** + * @test + */ + public function localhostAddressIsIgnored() + { + $visits = [ + (new Visit())->setRemoteAddr('1.2.3.4'), + (new Visit())->setRemoteAddr('4.3.2.1'), + (new Visit())->setRemoteAddr('12.34.56.78'), + (new Visit())->setRemoteAddr('127.0.0.1'), + (new Visit())->setRemoteAddr('127.0.0.1'), + ]; + $this->visitService->getUnlocatedVisits()->willReturn($visits) + ->shouldBeCalledTimes(1); + + $this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2); + $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]) + ->shouldBeCalledTimes(count($visits) - 2); + + $this->commandTester->execute([ + 'command' => 'visit:process', + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'Ignored localhost address') > 0); + } +} diff --git a/module/CLI/test/Command/ResolveUrlCommandTest.php b/module/CLI/test/Command/ResolveUrlCommandTest.php new file mode 100644 index 00000000..a2f47c92 --- /dev/null +++ b/module/CLI/test/Command/ResolveUrlCommandTest.php @@ -0,0 +1,85 @@ +urlShortener = $this->prophesize(UrlShortener::class); + $command = new ResolveUrlCommand($this->urlShortener->reveal(), Translator::factory([])); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function correctShortCodeResolvesUrl() + { + $shortCode = 'abc123'; + $expectedUrl = 'http://domain.com/foo/bar'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:parse', + 'shortCode' => $shortCode, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); + } + + /** + * @test + */ + public function incorrectShortCodeOutputsErrorMessage() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:parse', + 'shortCode' => $shortCode, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertEquals('No URL found for short code "' . $shortCode . '"' . PHP_EOL, $output); + } + + /** + * @test + */ + public function wrongShortCodeFormatOutputsErrorMessage() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException()) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:parse', + 'shortCode' => $shortCode, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertEquals('Provided short code "' . $shortCode . '" has an invalid format.' . PHP_EOL, $output); + } +} diff --git a/module/CLI/test/ConfigProviderTest.php b/module/CLI/test/ConfigProviderTest.php new file mode 100644 index 00000000..6231752a --- /dev/null +++ b/module/CLI/test/ConfigProviderTest.php @@ -0,0 +1,30 @@ +configProvider = new ConfigProvider(); + } + + /** + * @test + */ + public function confiIsProperlyReturned() + { + $config = $this->configProvider->__invoke(); + + $this->assertArrayHasKey('cli', $config); + $this->assertArrayHasKey('dependencies', $config); + $this->assertArrayHasKey('translator', $config); + } +} diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php new file mode 100644 index 00000000..1c486e55 --- /dev/null +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -0,0 +1,60 @@ +factory = new ApplicationFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke($this->createServiceManager(), ''); + $this->assertInstanceOf(Application::class, $instance); + } + + /** + * @test + */ + public function allCommandsWhichAreServicesAreAdded() + { + $sm = $this->createServiceManager([ + 'commands' => [ + 'foo', + 'bar', + 'baz', + ], + ]); + $sm->setService('foo', $this->prophesize(Command::class)->reveal()); + $sm->setService('baz', $this->prophesize(Command::class)->reveal()); + + /** @var Application $instance */ + $instance = $this->factory->__invoke($sm, ''); + $this->assertInstanceOf(Application::class, $instance); + $this->assertCount(2, $instance->all()); + } + + protected function createServiceManager($config = []) + { + return new ServiceManager(['services' => [ + 'config' => [ + 'cli' => $config, + ], + ]]); + } +} diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php new file mode 100644 index 00000000..bcd30faa --- /dev/null +++ b/module/Common/config/dependencies.config.php @@ -0,0 +1,38 @@ + [ + 'factories' => [ + EntityManager::class => EntityManagerFactory::class, + GuzzleHttp\Client::class => InvokableFactory::class, + Cache::class => CacheFactory::class, + IpLocationResolver::class => AnnotatedFactory::class, + Translator::class => TranslatorFactory::class, + TranslatorExtension::class => AnnotatedFactory::class, + LocaleMiddleware::class => AnnotatedFactory::class, + + ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class, + ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class, + ], + 'aliases' => [ + 'em' => EntityManager::class, + 'httpClient' => GuzzleHttp\Client::class, + 'translator' => Translator::class, + AnnotatedFactory::CACHE_SERVICE => Cache::class, + ], + ], + +]; diff --git a/module/Common/config/error-handler.config.php b/module/Common/config/error-handler.config.php new file mode 100644 index 00000000..d19b9ac6 --- /dev/null +++ b/module/Common/config/error-handler.config.php @@ -0,0 +1,22 @@ + [ + 'plugins' => [ + 'invokables' => [ + 'text/plain' => FinalHandler::class, + ], + 'factories' => [ + ContentBasedErrorHandler::DEFAULT_CONTENT => TemplatedErrorHandlerFactory::class, + ], + 'aliases' => [ + 'application/xhtml+xml' => ContentBasedErrorHandler::DEFAULT_CONTENT, + ], + ], + ], + +]; diff --git a/module/Common/config/middleware-pipeline.config.php b/module/Common/config/middleware-pipeline.config.php new file mode 100644 index 00000000..aab5af85 --- /dev/null +++ b/module/Common/config/middleware-pipeline.config.php @@ -0,0 +1,15 @@ + [ + 'pre-routing' => [ + 'middleware' => [ + Middleware\LocaleMiddleware::class, + ], + 'priority' => 5, + ], + ], + +]; diff --git a/module/Common/config/templates.config.php b/module/Common/config/templates.config.php new file mode 100644 index 00000000..903c1e8c --- /dev/null +++ b/module/Common/config/templates.config.php @@ -0,0 +1,12 @@ + [ + 'extensions' => [ + TranslatorExtension::class, + ], + ], + +]; diff --git a/module/Common/functions/functions.php b/module/Common/functions/functions.php new file mode 100644 index 00000000..f0f86f77 --- /dev/null +++ b/module/Common/functions/functions.php @@ -0,0 +1,36 @@ +errorHandlerManager = $errorHandlerManager; + } + + /** + * Final handler for an application. + * + * @param Request $request + * @param Response $response + * @param null|mixed $err + * @return Response + */ + public function __invoke(Request $request, Response $response, $err = null) + { + // Try to get an error handler for provided request accepted type + $errorHandler = $this->resolveErrorHandlerFromAcceptHeader($request); + return $errorHandler($request, $response, $err); + } + + /** + * Tries to resolve + * + * @param Request $request + * @return callable + */ + protected function resolveErrorHandlerFromAcceptHeader(Request $request) + { + // Try to find an error handler for one of the accepted content types + $accepts = $request->hasHeader('Accept') ? $request->getHeaderLine('Accept') : self::DEFAULT_CONTENT; + $accepts = explode(',', $accepts); + foreach ($accepts as $accept) { + if (! $this->errorHandlerManager->has($accept)) { + continue; + } + + return $this->errorHandlerManager->get($accept); + } + + // If it wasn't possible to find an error handler for accepted content type, use default one if registered + if ($this->errorHandlerManager->has(self::DEFAULT_CONTENT)) { + return $this->errorHandlerManager->get(self::DEFAULT_CONTENT); + } + + // It wasn't possible to find an error handler + throw new InvalidArgumentException(sprintf( + 'It wasn\'t possible to find an error handler for ["%s"] content types. ' + . 'Make sure you have registered at least the default "%s" content type', + implode('", "', $accepts), + self::DEFAULT_CONTENT + )); + } +} diff --git a/module/Common/src/ErrorHandler/ErrorHandlerInterface.php b/module/Common/src/ErrorHandler/ErrorHandlerInterface.php new file mode 100644 index 00000000..9676c40a --- /dev/null +++ b/module/Common/src/ErrorHandler/ErrorHandlerInterface.php @@ -0,0 +1,18 @@ +get('config')['error_handler']; + $plugins = isset($config['plugins']) ? $config['plugins'] : []; + return new ErrorHandlerManager($container, $plugins); + } +} diff --git a/module/Common/src/ErrorHandler/ErrorHandlerManagerInterface.php b/module/Common/src/ErrorHandler/ErrorHandlerManagerInterface.php new file mode 100644 index 00000000..323bfe4b --- /dev/null +++ b/module/Common/src/ErrorHandler/ErrorHandlerManagerInterface.php @@ -0,0 +1,9 @@ +get('config'); + if (isset($config['cache']) + && isset($config['cache']['adapter']) + && in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS) + ) { + return new $config['cache']['adapter'](); + } + + // If the adapter has not been set in config, create one based on environment + return env('APP_ENV', 'pro') === 'pro' ? new ApcuCache() : new ArrayCache(); + } +} diff --git a/src/Factory/EntityManagerFactory.php b/module/Common/src/Factory/EntityManagerFactory.php similarity index 94% rename from src/Factory/EntityManagerFactory.php rename to module/Common/src/Factory/EntityManagerFactory.php index 8716b3f8..e42abb40 100644 --- a/src/Factory/EntityManagerFactory.php +++ b/module/Common/src/Factory/EntityManagerFactory.php @@ -1,5 +1,5 @@ get('config'); + return Translator::factory(isset($config['translator']) ? $config['translator'] : []); } } diff --git a/module/Common/src/Middleware/LocaleMiddleware.php b/module/Common/src/Middleware/LocaleMiddleware.php new file mode 100644 index 00000000..20f796ff --- /dev/null +++ b/module/Common/src/Middleware/LocaleMiddleware.php @@ -0,0 +1,82 @@ +translator = $translator; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + if (! $request->hasHeader('Accept-Language')) { + return $out($request, $response); + } + + $locale = $request->getHeaderLine('Accept-Language'); + $this->translator->setLocale($this->normalizeLocale($locale)); + return $out($request, $response); + } + + /** + * @param string $locale + * @return string + */ + protected function normalizeLocale($locale) + { + $parts = explode('_', $locale); + if (count($parts) > 1) { + return $parts[0]; + } + + $parts = explode('-', $locale); + if (count($parts) > 1) { + return $parts[0]; + } + + return $locale; + } +} diff --git a/src/Paginator/Adapter/PaginableRepositoryAdapter.php b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php similarity index 91% rename from src/Paginator/Adapter/PaginableRepositoryAdapter.php rename to module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php index 243e4a1b..ade0c0c9 100644 --- a/src/Paginator/Adapter/PaginableRepositoryAdapter.php +++ b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php @@ -1,7 +1,7 @@ httpClient = $httpClient; + } + + /** + * @param $ipAddress + * @return array + */ + public function resolveIpLocation($ipAddress) + { + try { + $response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress)); + return json_decode($response->getBody(), true); + } catch (GuzzleException $e) { + throw WrongIpException::fromIpAddress($ipAddress, $e); + } + } +} diff --git a/module/Common/src/Service/IpLocationResolverInterface.php b/module/Common/src/Service/IpLocationResolverInterface.php new file mode 100644 index 00000000..350c2b9c --- /dev/null +++ b/module/Common/src/Service/IpLocationResolverInterface.php @@ -0,0 +1,11 @@ +translator = $translator; + } + + /** + * Returns the name of the extension. + * + * @return string The extension name + */ + public function getName() + { + return __CLASS__; + } + + public function getFunctions() + { + return [ + new \Twig_SimpleFunction('translate', [$this, 'translate']), + new \Twig_SimpleFunction('translate_plural', [$this, 'translatePlural']), + ]; + } + + /** + * Translate a message. + * + * @param string $message + * @param string $textDomain + * @param string $locale + * @return string + */ + public function translate($message, $textDomain = 'default', $locale = null) + { + return $this->translator->translate($message, $textDomain, $locale); + } + + /** + * Translate a plural message. + * + * @param string $singular + * @param string $plural + * @param int $number + * @param string $textDomain + * @param string|null $locale + * @return string + */ + public function translatePlural( + $singular, + $plural, + $number, + $textDomain = 'default', + $locale = null + ) { + $this->translator->translatePlural($singular, $plural, $number, $textDomain, $locale); + } +} diff --git a/module/Common/src/Util/DateRange.php b/module/Common/src/Util/DateRange.php new file mode 100644 index 00000000..c87f402a --- /dev/null +++ b/module/Common/src/Util/DateRange.php @@ -0,0 +1,44 @@ +startDate = $startDate; + $this->endDate = $endDate; + } + + /** + * @return \DateTimeInterface + */ + public function getStartDate() + { + return $this->startDate; + } + + /** + * @return \DateTimeInterface + */ + public function getEndDate() + { + return $this->endDate; + } + + /** + * @return bool + */ + public function isEmpty() + { + return is_null($this->startDate) && is_null($this->endDate); + } +} diff --git a/src/Util/StringUtilsTrait.php b/module/Common/src/Util/StringUtilsTrait.php similarity index 96% rename from src/Util/StringUtilsTrait.php rename to module/Common/src/Util/StringUtilsTrait.php index 2b4bf625..648dbfcb 100644 --- a/src/Util/StringUtilsTrait.php +++ b/module/Common/src/Util/StringUtilsTrait.php @@ -1,5 +1,5 @@ configProvider = new ConfigProvider(); + } + + /** + * @test + */ + public function configIsReturned() + { + $config = $this->configProvider->__invoke(); + + $this->assertArrayHasKey('error_handler', $config); + $this->assertArrayHasKey('middleware_pipeline', $config); + $this->assertArrayHasKey('dependencies', $config); + $this->assertArrayHasKey('twig', $config); + } +} diff --git a/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php b/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php new file mode 100644 index 00000000..6b480e54 --- /dev/null +++ b/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php @@ -0,0 +1,75 @@ +errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [ + 'factories' => [ + 'text/html' => [$this, 'factory'], + 'application/json' => [$this, 'factory'], + ], + ])); + } + + public function factory($container, $name) + { + return function () use ($name) { + return $name; + }; + } + + /** + * @test + */ + public function correctAcceptHeaderValueInvokesErrorHandler() + { + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,application/json'); + $result = $this->errorHandler->__invoke($request, new Response()); + $this->assertEquals('application/json', $result); + } + + /** + * @test + */ + public function defaultContentTypeIsUsedWhenNoAcceptHeaderisPresent() + { + $request = ServerRequestFactory::fromGlobals(); + $result = $this->errorHandler->__invoke($request, new Response()); + $this->assertEquals('text/html', $result); + } + + /** + * @test + */ + public function defaultContentTypeIsUsedWhenAcceptedContentIsNotSupported() + { + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml'); + $result = $this->errorHandler->__invoke($request, new Response()); + $this->assertEquals('text/html', $result); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException + */ + public function ifNoErrorHandlerIsFoundAnExceptionIsThrown() + { + $this->errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [])); + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml'); + $result = $this->errorHandler->__invoke($request, new Response()); + } +} diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php new file mode 100644 index 00000000..be6d4e6d --- /dev/null +++ b/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php @@ -0,0 +1,35 @@ +factory = new ErrorHandlerManagerFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(['services' => [ + 'config' => [ + 'error_handler' => [ + 'plugins' => [], + ], + ], + ]]), ''); + $this->assertInstanceOf(ErrorHandlerManager::class, $instance); + } +} diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php new file mode 100644 index 00000000..4b14f113 --- /dev/null +++ b/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php @@ -0,0 +1,45 @@ +pluginManager = new ErrorHandlerManager(new ServiceManager(), [ + 'services' => [ + 'foo' => function () { + }, + ], + 'invokables' => [ + 'invalid' => \stdClass::class, + ] + ]); + } + + /** + * @test + */ + public function callablesAreReturned() + { + $instance = $this->pluginManager->get('foo'); + $this->assertInstanceOf(\Closure::class, $instance); + } + + /** + * @test + * @expectedException \Zend\ServiceManager\Exception\InvalidServiceException + */ + public function nonCallablesThrowException() + { + $this->pluginManager->get('invalid'); + } +} diff --git a/module/Common/test/Factory/CacheFactoryTest.php b/module/Common/test/Factory/CacheFactoryTest.php new file mode 100644 index 00000000..2e938dfa --- /dev/null +++ b/module/Common/test/Factory/CacheFactoryTest.php @@ -0,0 +1,76 @@ +factory = new CacheFactory(); + } + + public static function tearDownAfterClass() + { + putenv('APP_ENV'); + } + + /** + * @test + */ + public function productionReturnsApcAdapter() + { + putenv('APP_ENV=pro'); + $instance = $this->factory->__invoke($this->createSM(), ''); + $this->assertInstanceOf(ApcuCache::class, $instance); + } + + /** + * @test + */ + public function developmentReturnsArrayAdapter() + { + putenv('APP_ENV=dev'); + $instance = $this->factory->__invoke($this->createSM(), ''); + $this->assertInstanceOf(ArrayCache::class, $instance); + } + + /** + * @test + */ + public function adapterDefinedInConfigIgnoresEnvironment() + { + putenv('APP_ENV=pro'); + $instance = $this->factory->__invoke($this->createSM(ArrayCache::class), ''); + $this->assertInstanceOf(ArrayCache::class, $instance); + } + + /** + * @test + */ + public function invalidAdapterDefinedInConfigFallbacksToEnvironment() + { + putenv('APP_ENV=pro'); + $instance = $this->factory->__invoke($this->createSM(FilesystemCache::class), ''); + $this->assertInstanceOf(ApcuCache::class, $instance); + } + + private function createSM($cacheAdapter = null) + { + return new ServiceManager(['services' => [ + 'config' => isset($cacheAdapter) ? [ + 'cache' => ['adapter' => $cacheAdapter], + ] : [], + ]]); + } +} diff --git a/tests/Factory/EntityManagerFactoryTest.php b/module/Common/test/Factory/EntityManagerFactoryTest.php similarity index 88% rename from tests/Factory/EntityManagerFactoryTest.php rename to module/Common/test/Factory/EntityManagerFactoryTest.php index 4b9bd82f..53c839ed 100644 --- a/tests/Factory/EntityManagerFactoryTest.php +++ b/module/Common/test/Factory/EntityManagerFactoryTest.php @@ -1,9 +1,9 @@ factory = new TranslatorFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(['services' => [ + 'config' => [], + ]]), ''); + $this->assertInstanceOf(Translator::class, $instance); + } +} diff --git a/module/Common/test/Middleware/LocaleMiddlewareTest.php b/module/Common/test/Middleware/LocaleMiddlewareTest.php new file mode 100644 index 00000000..72e6bf77 --- /dev/null +++ b/module/Common/test/Middleware/LocaleMiddlewareTest.php @@ -0,0 +1,71 @@ +translator = Translator::factory(['locale' => 'ru']); + $this->middleware = new LocaleMiddleware($this->translator); + } + + /** + * @test + */ + public function whenNoHeaderIsPresentLocaleIsNotChanged() + { + $this->assertEquals('ru', $this->translator->getLocale()); + $this->middleware->__invoke(ServerRequestFactory::fromGlobals(), new Response(), function ($req, $resp) { + return $resp; + }); + $this->assertEquals('ru', $this->translator->getLocale()); + } + + /** + * @test + */ + public function whenTheHeaderIsPresentLocaleIsChanged() + { + $this->assertEquals('ru', $this->translator->getLocale()); + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es'); + $this->middleware->__invoke($request, new Response(), function ($req, $resp) { + return $resp; + }); + $this->assertEquals('es', $this->translator->getLocale()); + } + + /** + * @test + */ + public function localeGetsNormalized() + { + $this->assertEquals('ru', $this->translator->getLocale()); + + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es_ES'); + $this->middleware->__invoke($request, new Response(), function ($req, $resp) { + return $resp; + }); + $this->assertEquals('es', $this->translator->getLocale()); + + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'en-US'); + $this->middleware->__invoke($request, new Response(), function ($req, $resp) { + return $resp; + }); + $this->assertEquals('en', $this->translator->getLocale()); + } +} diff --git a/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php new file mode 100644 index 00000000..1135682f --- /dev/null +++ b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php @@ -0,0 +1,43 @@ +repo = $this->prophesize(PaginableRepositoryInterface::class); + $this->adapter = new PaginableRepositoryAdapter($this->repo->reveal(), 'search', 'order'); + } + + /** + * @test + */ + public function getItemsFallbacksToFindList() + { + $this->repo->findList(10, 5, 'search', 'order')->shouldBeCalledTimes(1); + $this->adapter->getItems(5, 10); + } + + /** + * @test + */ + public function countFallbacksToCountList() + { + $this->repo->countList('search')->shouldBeCalledTimes(1); + $this->adapter->count(); + } +} diff --git a/module/Common/test/Service/IpLocationResolverTest.php b/module/Common/test/Service/IpLocationResolverTest.php new file mode 100644 index 00000000..e6130569 --- /dev/null +++ b/module/Common/test/Service/IpLocationResolverTest.php @@ -0,0 +1,56 @@ +client = $this->prophesize(Client::class); + $this->ipResolver = new IpLocationResolver($this->client->reveal()); + } + + /** + * @test + */ + public function correctIpReturnsDecodedInfo() + { + $expected = [ + 'foo' => 'bar', + 'baz' => 'foo', + ]; + $response = new Response(); + $response->getBody()->write(json_encode($expected)); + $response->getBody()->rewind(); + + $this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response) + ->shouldBeCalledTimes(1); + $this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4')); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException + */ + public function guzzleExceptionThrowsShlinkException() + { + $this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException()) + ->shouldBeCalledTimes(1); + $this->ipResolver->resolveIpLocation('1.2.3.4'); + } +} diff --git a/module/Common/test/Twig/Extension/TranslatorExtensionTest.php b/module/Common/test/Twig/Extension/TranslatorExtensionTest.php new file mode 100644 index 00000000..06b5a584 --- /dev/null +++ b/module/Common/test/Twig/Extension/TranslatorExtensionTest.php @@ -0,0 +1,70 @@ +translator = $this->prophesize(Translator::class); + $this->extension = new TranslatorExtension($this->translator->reveal()); + } + + /** + * @test + */ + public function extensionNameIsClassName() + { + $this->assertEquals(TranslatorExtension::class, $this->extension->getName()); + } + + /** + * @test + */ + public function properFunctionsAreReturned() + { + $funcs = $this->extension->getFunctions(); + $this->assertCount(2, $funcs); + foreach ($funcs as $func) { + $this->assertInstanceOf(\Twig_SimpleFunction::class, $func); + } + } + + /** + * @test + */ + public function translateFallbacksToTranslator() + { + $this->translator->translate('foo', 'default', null)->shouldBeCalledTimes(1); + $this->extension->translate('foo'); + + $this->translator->translate('bar', 'baz', 'en')->shouldBeCalledTimes(1); + $this->extension->translate('bar', 'baz', 'en'); + } + + /** + * @test + */ + public function translatePluralFallbacksToTranslator() + { + $this->translator->translatePlural('foo', 'bar', 'baz', 'default', null)->shouldBeCalledTimes(1); + $this->extension->translatePlural('foo', 'bar', 'baz'); + + $this->translator->translatePlural('foo', 'bar', 'baz', 'another', 'en')->shouldBeCalledTimes(1); + $this->extension->translatePlural('foo', 'bar', 'baz', 'another', 'en'); + } +} diff --git a/module/Common/test/Util/DateRangeTest.php b/module/Common/test/Util/DateRangeTest.php new file mode 100644 index 00000000..4ed1586e --- /dev/null +++ b/module/Common/test/Util/DateRangeTest.php @@ -0,0 +1,32 @@ +assertNull($range->getStartDate()); + $this->assertNull($range->getEndDate()); + $this->assertTrue($range->isEmpty()); + } + + /** + * @test + */ + public function providedDatesAreSet() + { + $startDate = new \DateTime(); + $endDate = new \DateTime(); + $range = new DateRange($startDate, $endDate); + $this->assertSame($startDate, $range->getStartDate()); + $this->assertSame($endDate, $range->getEndDate()); + $this->assertFalse($range->isEmpty()); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php new file mode 100644 index 00000000..9bcacbfa --- /dev/null +++ b/module/Core/config/dependencies.config.php @@ -0,0 +1,21 @@ + [ + 'factories' => [ + // Services + Service\UrlShortener::class => AnnotatedFactory::class, + Service\VisitsTracker::class => AnnotatedFactory::class, + Service\ShortUrlService::class => AnnotatedFactory::class, + Service\VisitService::class => AnnotatedFactory::class, + + // Middleware + RedirectAction::class => AnnotatedFactory::class, + ], + ], + +]; diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php new file mode 100644 index 00000000..4d9a85e5 --- /dev/null +++ b/module/Core/config/routes.config.php @@ -0,0 +1,15 @@ + [ + [ + 'name' => 'long-url-redirect', + 'path' => '/{shortCode}', + 'middleware' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ], + +]; diff --git a/module/Core/config/templates.config.php b/module/Core/config/templates.config.php new file mode 100644 index 00000000..da3623c1 --- /dev/null +++ b/module/Core/config/templates.config.php @@ -0,0 +1,11 @@ + [ + 'paths' => [ + 'module/Core/templates', + ], + ], + +]; diff --git a/module/Core/config/translator.config.php b/module/Core/config/translator.config.php new file mode 100644 index 00000000..ae120db3 --- /dev/null +++ b/module/Core/config/translator.config.php @@ -0,0 +1,14 @@ + [ + 'translation_file_patterns' => [ + [ + 'type' => 'gettext', + 'base_dir' => __DIR__ . '/../lang', + 'pattern' => '%s.mo', + ], + ], + ], + +]; diff --git a/module/Core/config/zend-expressive.config.php b/module/Core/config/zend-expressive.config.php new file mode 100644 index 00000000..c5fefe8f --- /dev/null +++ b/module/Core/config/zend-expressive.config.php @@ -0,0 +1,12 @@ + [ + 'error_handler' => [ + 'template_404' => 'core/error/404.html.twig', + 'template_error' => 'core/error/error.html.twig', + ], + ], + +]; diff --git a/module/Core/lang/es.mo b/module/Core/lang/es.mo new file mode 100644 index 00000000..d34bb83b Binary files /dev/null and b/module/Core/lang/es.mo differ diff --git a/module/Core/lang/es.po b/module/Core/lang/es.po new file mode 100644 index 00000000..3393f072 --- /dev/null +++ b/module/Core/lang/es.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: Shlink 1.0\n" +"POT-Creation-Date: 2016-07-21 16:50+0200\n" +"PO-Revision-Date: 2016-07-21 16:51+0200\n" +"Last-Translator: \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-Poedit-Basepath: ..\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-KeywordsList: translate;translaePlural;translate_plural\n" +"X-Poedit-SearchPath-0: templates\n" +"X-Poedit-SearchPath-1: config\n" +"X-Poedit-SearchPath-2: src\n" + +msgid "Make sure you included all the characters, with no extra punctuation." +msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra." + +msgid "Oops!" +msgstr "¡Vaya!" + +msgid "This short URL doesn't seem to be valid." +msgstr "Esta URL acortada no parece ser válida." + +msgid "URL Not Found" +msgstr "URL no encontrada" + +#, php-format +msgid "We encountered a %s %s error." +msgstr "Hemos encontrado un error %s %s." diff --git a/src/Middleware/Routable/RedirectMiddleware.php b/module/Core/src/Action/RedirectAction.php similarity index 74% rename from src/Middleware/Routable/RedirectMiddleware.php rename to module/Core/src/Action/RedirectAction.php index 85b42eb6..031aa0fe 100644 --- a/src/Middleware/Routable/RedirectMiddleware.php +++ b/module/Core/src/Action/RedirectAction.php @@ -1,17 +1,17 @@ urlShortener->shortCodeToUrl($shortCode); - // If provided shortCode does not belong to a valid long URL, dispatch next middleware, which is 404 - // middleware + // If provided shortCode does not belong to a valid long URL, dispatch next middleware, which will trigger + // a not-found error if (! isset($longUrl)) { - return $out($request, $response); + return $this->notFoundResponse($request, $response, $out); } - // Track visit to this shortcode + // Track visit to this short code $this->visitTracker->track($shortCode); // Return a redirect response to the long URL. @@ -81,7 +81,18 @@ class RedirectMiddleware implements MiddlewareInterface return new RedirectResponse($longUrl); } catch (\Exception $e) { // In case of error, dispatch 404 error - return $out($request, $response); + return $this->notFoundResponse($request, $response, $out); } } + + /** + * @param Request $request + * @param Response $response + * @param callable $out + * @return Response + */ + protected function notFoundResponse(Request $request, Response $response, callable $out) + { + return $out($request, $response->withStatus(404), 'Not Found'); + } } diff --git a/module/Core/src/ConfigProvider.php b/module/Core/src/ConfigProvider.php new file mode 100644 index 00000000..927e126d --- /dev/null +++ b/module/Core/src/ConfigProvider.php @@ -0,0 +1,13 @@ +visitLocation; + } + + /** + * @param VisitLocation $visitLocation + * @return $this + */ + public function setVisitLocation($visitLocation) + { + $this->visitLocation = $visitLocation; + return $this; + } + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php @@ -149,6 +174,7 @@ class Visit extends AbstractEntity implements \JsonSerializable 'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null, 'remoteAddr' => $this->remoteAddr, 'userAgent' => $this->userAgent, + 'visitLocation' => $this->visitLocation, ]; } } diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php new file mode 100644 index 00000000..3b9851ac --- /dev/null +++ b/module/Core/src/Entity/VisitLocation.php @@ -0,0 +1,240 @@ +countryCode; + } + + /** + * @param string $countryCode + * @return $this + */ + public function setCountryCode($countryCode) + { + $this->countryCode = $countryCode; + return $this; + } + + /** + * @return string + */ + public function getCountryName() + { + return $this->countryName; + } + + /** + * @param string $countryName + * @return $this + */ + public function setCountryName($countryName) + { + $this->countryName = $countryName; + return $this; + } + + /** + * @return string + */ + public function getRegionName() + { + return $this->regionName; + } + + /** + * @param string $regionName + * @return $this + */ + public function setRegionName($regionName) + { + $this->regionName = $regionName; + return $this; + } + + /** + * @return string + */ + public function getCityName() + { + return $this->cityName; + } + + /** + * @param string $cityName + * @return $this + */ + public function setCityName($cityName) + { + $this->cityName = $cityName; + return $this; + } + + /** + * @return string + */ + public function getLatitude() + { + return $this->latitude; + } + + /** + * @param string $latitude + * @return $this + */ + public function setLatitude($latitude) + { + $this->latitude = $latitude; + return $this; + } + + /** + * @return string + */ + public function getLongitude() + { + return $this->longitude; + } + + /** + * @param string $longitude + * @return $this + */ + public function setLongitude($longitude) + { + $this->longitude = $longitude; + return $this; + } + + /** + * @return string + */ + public function getTimezone() + { + return $this->timezone; + } + + /** + * @param string $timezone + * @return $this + */ + public function setTimezone($timezone) + { + $this->timezone = $timezone; + return $this; + } + + /** + * Exchange internal values from provided array + * + * @param array $array + * @return void + */ + public function exchangeArray(array $array) + { + if (array_key_exists('country_code', $array)) { + $this->setCountryCode($array['country_code']); + } + if (array_key_exists('country_name', $array)) { + $this->setCountryName($array['country_name']); + } + if (array_key_exists('region_name', $array)) { + $this->setRegionName($array['region_name']); + } + if (array_key_exists('city', $array)) { + $this->setCityName($array['city']); + } + if (array_key_exists('latitude', $array)) { + $this->setLatitude($array['latitude']); + } + if (array_key_exists('longitude', $array)) { + $this->setLongitude($array['longitude']); + } + if (array_key_exists('time_zone', $array)) { + $this->setTimezone($array['time_zone']); + } + } + + /** + * Return an array representation of the object + * + * @return array + */ + public function getArrayCopy() + { + return [ + 'countryCode' => $this->countryCode, + 'countryName' => $this->countryName, + 'regionName' => $this->regionName, + 'cityName' => $this->cityName, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'timezone' => $this->timezone, + ]; + } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return $this->getArrayCopy(); + } +} diff --git a/src/Exception/InvalidShortCodeException.php b/module/Core/src/Exception/InvalidShortCodeException.php similarity index 80% rename from src/Exception/InvalidShortCodeException.php rename to module/Core/src/Exception/InvalidShortCodeException.php index b41038a1..b1e23d0b 100644 --- a/src/Exception/InvalidShortCodeException.php +++ b/module/Core/src/Exception/InvalidShortCodeException.php @@ -1,5 +1,7 @@ createQueryBuilder('v'); + $qb->where($qb->expr()->isNull('v.visitLocation')); + + return $qb->getQuery()->getResult(); + } + + /** + * @param ShortUrl|int $shortUrl + * @param DateRange|null $dateRange + * @return Visit[] + */ + public function findVisitsByShortUrl($shortUrl, DateRange $dateRange = null) + { + $shortUrl = $shortUrl instanceof ShortUrl + ? $shortUrl + : $this->getEntityManager()->find(ShortUrl::class, $shortUrl); + if (! isset($dateRange) || $dateRange->isEmpty()) { + $startDate = $shortUrl->getDateCreated(); + $endDate = clone $startDate; + $endDate->add(new \DateInterval('P2D')); + $dateRange = new DateRange($startDate, $endDate); + } + + $qb = $this->createQueryBuilder('v'); + $qb->where($qb->expr()->eq('v.shortUrl', ':shortUrl')) + ->setParameter('shortUrl', $shortUrl) + ->orderBy('v.date', 'DESC') ; + + // Apply date range filtering + if (! empty($dateRange->getStartDate())) { + $qb->andWhere($qb->expr()->gte('v.date', ':startDate')) + ->setParameter('startDate', $dateRange->getStartDate()); + } + if (! empty($dateRange->getEndDate())) { + $qb->andWhere($qb->expr()->lte('v.date', ':endDate')) + ->setParameter('endDate', $dateRange->getEndDate()); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php new file mode 100644 index 00000000..c65f495d --- /dev/null +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -0,0 +1,22 @@ +httpClient = $httpClient; $this->em = $em; - $this->chars = $chars; + $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; } /** diff --git a/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php similarity index 74% rename from src/Service/UrlShortenerInterface.php rename to module/Core/src/Service/UrlShortenerInterface.php index 6623f392..07d45401 100644 --- a/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -1,10 +1,10 @@ em = $em; + } + + /** + * @return Visit[] + */ + public function getUnlocatedVisits() + { + /** @var VisitRepository $repo */ + $repo = $this->em->getRepository(Visit::class); + return $repo->findUnlocatedVisits(); + } + + /** + * @param Visit $visit + */ + public function saveVisit(Visit $visit) + { + $this->em->persist($visit); + $this->em->flush(); + } +} diff --git a/module/Core/src/Service/VisitServiceInterface.php b/module/Core/src/Service/VisitServiceInterface.php new file mode 100644 index 00000000..8347fdb3 --- /dev/null +++ b/module/Core/src/Service/VisitServiceInterface.php @@ -0,0 +1,17 @@ +em->getRepository(ShortUrl::class)->findOneBy([ @@ -78,10 +79,8 @@ class VisitsTracker implements VisitsTrackerInterface throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode)); } - return $this->em->getRepository(Visit::class)->findBy([ - 'shortUrl' => $shortUrl, - ], [ - 'date' => 'DESC' - ]); + /** @var VisitRepository $repo */ + $repo = $this->em->getRepository(Visit::class); + return $repo->findVisitsByShortUrl($shortUrl, $dateRange); } } diff --git a/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php similarity index 55% rename from src/Service/VisitsTrackerInterface.php rename to module/Core/src/Service/VisitsTrackerInterface.php index ce0a61cf..cec254d3 100644 --- a/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -1,8 +1,8 @@ + p {margin-bottom: 20px;} + body {text-align: center;} + +{% endblock %} + +{% block content %} +

{{ translate('Oops!') }}

+
+

{{ translate('This short URL doesn\'t seem to be valid.') }}

+

{{ translate('Make sure you included all the characters, with no extra punctuation.') }}

+{% endblock %} diff --git a/module/Core/templates/core/error/error.html.twig b/module/Core/templates/core/error/error.html.twig new file mode 100644 index 00000000..5cb66c57 --- /dev/null +++ b/module/Core/templates/core/error/error.html.twig @@ -0,0 +1,21 @@ +{% extends 'core/layout/default.html.twig' %} + +{% block title %}{{ status }} {{ reason }}{% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block content %} +

{{ translate('Oops!') }}

+
+ {% if status != 404 %} +

{{ translate('We encountered a %s %s error.') | format(status, reason) }}

+ {% else %} +

{{ translate('This short URL doesn\'t seem to be valid.') }}

+

{{ translate('Make sure you included all the characters, with no extra punctuation.') }}

+ {% endif %} +{% endblock %} diff --git a/templates/layout/default.html.twig b/module/Core/templates/core/layout/default.html.twig similarity index 100% rename from templates/layout/default.html.twig rename to module/Core/templates/core/layout/default.html.twig diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php new file mode 100644 index 00000000..434189fc --- /dev/null +++ b/module/Core/test/Action/RedirectActionTest.php @@ -0,0 +1,104 @@ +urlShortener = $this->prophesize(UrlShortener::class); + $visitTracker = $this->prophesize(VisitsTracker::class); + $visitTracker->track(Argument::any()); + $this->action = new RedirectAction($this->urlShortener->reveal(), $visitTracker->reveal()); + } + + /** + * @test + */ + public function redirectionIsPerformedToLongUrl() + { + $shortCode = 'abc123'; + $expectedUrl = 'http://domain.com/foo/bar'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $response = $this->action->__invoke($request, new Response()); + + $this->assertInstanceOf(Response\RedirectResponse::class, $response); + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue($response->hasHeader('Location')); + $this->assertEquals($expectedUrl, $response->getHeaderLine('Location')); + } + + /** + * @test + */ + public function nextErrorMiddlewareIsInvokedIfLongUrlIsNotFound() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $originalResponse = new Response(); + $test = $this; + $this->action->__invoke($request, $originalResponse, function ( + ServerRequestInterface $req, + ResponseInterface $resp, + $error + ) use ( + $test, + $request + ) { + $test->assertSame($request, $req); + $test->assertEquals(404, $resp->getStatusCode()); + $test->assertEquals('Not Found', $error); + }); + } + + /** + * @test + */ + public function nextErrorMiddlewareIsInvokedIfAnExceptionIsThrown() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $originalResponse = new Response(); + $test = $this; + $this->action->__invoke($request, $originalResponse, function ( + ServerRequestInterface $req, + ResponseInterface $resp, + $error + ) use ( + $test, + $request + ) { + $test->assertSame($request, $req); + $test->assertEquals(404, $resp->getStatusCode()); + $test->assertEquals('Not Found', $error); + }); + } +} diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php new file mode 100644 index 00000000..3af8a48a --- /dev/null +++ b/module/Core/test/ConfigProviderTest.php @@ -0,0 +1,32 @@ +configProvider = new ConfigProvider(); + } + + /** + * @test + */ + public function properConfigIsReturned() + { + $config = $this->configProvider->__invoke(); + + $this->assertArrayHasKey('routes', $config); + $this->assertArrayHasKey('dependencies', $config); + $this->assertArrayHasKey('templates', $config); + $this->assertArrayHasKey('translator', $config); + $this->assertArrayHasKey('zend-expressive', $config); + } +} diff --git a/tests/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php similarity index 86% rename from tests/Service/ShortUrlServiceTest.php rename to module/Core/test/Service/ShortUrlServiceTest.php index 3b77ee6a..1244d3a5 100644 --- a/tests/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -1,13 +1,13 @@ em = $this->prophesize(EntityManager::class); + $this->visitService = new VisitService($this->em->reveal()); + } + + /** + * @test + */ + public function saveVisitsPersistsProvidedVisit() + { + $visit = new Visit(); + $this->em->persist($visit)->shouldBeCalledTimes(1); + $this->em->flush()->shouldBeCalledTimes(1); + $this->visitService->saveVisit($visit); + } + + /** + * @test + */ + public function getUnlocatedVisitsFallbacksToRepository() + { + $repo = $this->prophesize(VisitRepository::class); + $repo->findUnlocatedVisits()->shouldBeCalledTimes(1); + $this->em->getRepository(Visit::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + $this->visitService->getUnlocatedVisits(); + } +} diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php new file mode 100644 index 00000000..4d8a68fe --- /dev/null +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -0,0 +1,68 @@ +em = $this->prophesize(EntityManager::class); + $this->visitsTracker = new VisitsTracker($this->em->reveal()); + } + + /** + * @test + */ + public function trackPersistsVisit() + { + $shortCode = '123ABC'; + $repo = $this->prophesize(EntityRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl()); + + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + $this->em->persist(Argument::any())->shouldBeCalledTimes(1); + $this->em->flush()->shouldBeCalledTimes(1); + + $this->visitsTracker->track($shortCode); + } + + /** + * @test + */ + public function infoReturnsVisistForCertainShortCode() + { + $shortCode = '123ABC'; + $shortUrl = (new ShortUrl())->setOriginalUrl('http://domain.com/foo/bar'); + $repo = $this->prophesize(EntityRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + + $list = [ + new Visit(), + new Visit(), + ]; + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByShortUrl($shortUrl, null)->willReturn($list); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledTimes(1); + + $this->assertEquals($list, $this->visitsTracker->info($shortCode)); + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php new file mode 100644 index 00000000..e04c8ba0 --- /dev/null +++ b/module/Rest/config/dependencies.config.php @@ -0,0 +1,25 @@ + [ + 'factories' => [ + Service\RestTokenService::class => AnnotatedFactory::class, + + Action\AuthenticateAction::class => AnnotatedFactory::class, + Action\CreateShortcodeAction::class => AnnotatedFactory::class, + Action\ResolveUrlAction::class => AnnotatedFactory::class, + Action\GetVisitsAction::class => AnnotatedFactory::class, + Action\ListShortcodesAction::class => AnnotatedFactory::class, + + Middleware\CrossDomainMiddleware::class => InvokableFactory::class, + Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, + ], + ], + +]; diff --git a/module/Rest/config/error-handler.config.php b/module/Rest/config/error-handler.config.php new file mode 100644 index 00000000..fef71303 --- /dev/null +++ b/module/Rest/config/error-handler.config.php @@ -0,0 +1,18 @@ + [ + 'plugins' => [ + 'invokables' => [ + 'application/json' => JsonErrorHandler::class, + ], + 'aliases' => [ + 'application/x-json' => 'application/json', + 'text/json' => 'application/json', + ], + ], + ], + +]; diff --git a/module/Rest/config/middleware-pipeline.config.php b/module/Rest/config/middleware-pipeline.config.php new file mode 100644 index 00000000..78c20c38 --- /dev/null +++ b/module/Rest/config/middleware-pipeline.config.php @@ -0,0 +1,16 @@ + [ + 'rest' => [ + 'path' => '/rest', + 'middleware' => [ + Middleware\CheckAuthenticationMiddleware::class, + Middleware\CrossDomainMiddleware::class, + ], + 'priority' => 5, + ], + ], +]; diff --git a/module/Rest/config/rest.config.php b/module/Rest/config/rest.config.php new file mode 100644 index 00000000..223c864f --- /dev/null +++ b/module/Rest/config/rest.config.php @@ -0,0 +1,9 @@ + [ + 'username' => env('REST_USER'), + 'password' => env('REST_PASSWORD'), + ], + +]; diff --git a/config/autoload/routes.global.php b/module/Rest/config/routes.config.php similarity index 54% rename from config/autoload/routes.global.php rename to module/Rest/config/routes.config.php index 87133f09..4cc0f510 100644 --- a/config/autoload/routes.global.php +++ b/module/Rest/config/routes.config.php @@ -1,46 +1,37 @@ [ - [ - 'name' => 'long-url-redirect', - 'path' => '/{shortCode}', - 'middleware' => Routable\RedirectMiddleware::class, - 'allowed_methods' => ['GET'], - ], - - // Rest [ 'name' => 'rest-authenticate', 'path' => '/rest/authenticate', - 'middleware' => Rest\AuthenticateMiddleware::class, + 'middleware' => Action\AuthenticateAction::class, 'allowed_methods' => ['POST', 'OPTIONS'], ], [ 'name' => 'rest-create-shortcode', 'path' => '/rest/short-codes', - 'middleware' => Rest\CreateShortcodeMiddleware::class, + 'middleware' => Action\CreateShortcodeAction::class, 'allowed_methods' => ['POST', 'OPTIONS'], ], [ 'name' => 'rest-resolve-url', 'path' => '/rest/short-codes/{shortCode}', - 'middleware' => Rest\ResolveUrlMiddleware::class, + 'middleware' => Action\ResolveUrlAction::class, 'allowed_methods' => ['GET', 'OPTIONS'], ], [ 'name' => 'rest-list-shortened-url', 'path' => '/rest/short-codes', - 'middleware' => Rest\ListShortcodesMiddleware::class, + 'middleware' => Action\ListShortcodesAction::class, 'allowed_methods' => ['GET'], ], [ 'name' => 'rest-get-visits', - 'path' => '/rest/visits/{shortCode}', - 'middleware' => Rest\GetVisitsMiddleware::class, + 'path' => '/rest/short-codes/{shortCode}/visits', + 'middleware' => Action\GetVisitsAction::class, 'allowed_methods' => ['GET', 'OPTIONS'], ], ], diff --git a/module/Rest/config/translator.config.php b/module/Rest/config/translator.config.php new file mode 100644 index 00000000..ae120db3 --- /dev/null +++ b/module/Rest/config/translator.config.php @@ -0,0 +1,14 @@ + [ + 'translation_file_patterns' => [ + [ + 'type' => 'gettext', + 'base_dir' => __DIR__ . '/../lang', + 'pattern' => '%s.mo', + ], + ], + ], + +]; diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo new file mode 100644 index 00000000..1bfa9aef Binary files /dev/null and b/module/Rest/lang/es.mo differ diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po new file mode 100644 index 00000000..62f76e1d --- /dev/null +++ b/module/Rest/lang/es.po @@ -0,0 +1,63 @@ +msgid "" +msgstr "" +"Project-Id-Version: Shlink 1.0\n" +"POT-Creation-Date: 2016-07-27 08:53+0200\n" +"PO-Revision-Date: 2016-07-27 08:53+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-Poedit-Basepath: ..\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-KeywordsList: translate;translatePlural\n" +"X-Poedit-SearchPath-0: config\n" +"X-Poedit-SearchPath-1: src\n" + +msgid "You have to provide both \"username\" and \"password\"" +msgstr "Debes proporcionar tanto \"username\" como \"password\"" + +msgid "Invalid username and/or password" +msgstr "Usuario y/o contraseña no válidos" + +msgid "A URL was not provided" +msgstr "No se ha proporcionado una URL" + +#, php-format +msgid "Provided URL \"%s\" is invalid. Try with a different one." +msgstr "" +"La URL \"%s\" proporcionada es inválida. Inténtalo de nuevo con una " +"diferente." + +msgid "Unexpected error occurred" +msgstr "Ocurrió un error inesperado" + +#, php-format +msgid "Provided short code \"%s\" is invalid" +msgstr "El código corto \"%s\" proporcionado es inválido" + +#, php-format +msgid "No URL found for shortcode \"%s\"" +msgstr "No se ha encontrado una URL para el código corto \"%s\"" + +#, php-format +msgid "Provided short code \"%s\" has an invalid format" +msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido" + +#, php-format +msgid "" +"Missing or invalid auth token provided. Perform a new authentication request " +"and send provided token on every new request on the \"%s\" header" +msgstr "" +"No se ha proporcionado token de autenticación o este es inválido. Realiza " +"una nueva petición de autenticación y envía el token proporcionado en cada " +"nueva petición en la cabecera \"%s\"" + +msgid "Requested route does not exist." +msgstr "La ruta solicitada no existe." + +#~ msgid "RestToken not found for token \"%s\"" +#~ msgstr "No se ha encontrado un RestToken para el token \"%s\"" diff --git a/src/Middleware/Rest/AbstractRestMiddleware.php b/module/Rest/src/Action/AbstractRestAction.php similarity index 89% rename from src/Middleware/Rest/AbstractRestMiddleware.php rename to module/Rest/src/Action/AbstractRestAction.php index 1168ff60..587e1a93 100644 --- a/src/Middleware/Rest/AbstractRestMiddleware.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -1,11 +1,11 @@ getMethod()) === 'options') { + if ($request->getMethod() === 'OPTIONS') { return $response; } diff --git a/src/Middleware/Rest/AuthenticateMiddleware.php b/module/Rest/src/Action/AuthenticateAction.php similarity index 61% rename from src/Middleware/Rest/AuthenticateMiddleware.php rename to module/Rest/src/Action/AuthenticateAction.php index 88c9df60..7d564e4f 100644 --- a/src/Middleware/Rest/AuthenticateMiddleware.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -1,31 +1,38 @@ restTokenService = $restTokenService; + $this->translator = $translator; } /** @@ -40,7 +47,7 @@ class AuthenticateMiddleware extends AbstractRestMiddleware if (! isset($authData['username'], $authData['password'])) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => 'You have to provide both "username" and "password"' + 'message' => $this->translator->translate('You have to provide both "username" and "password"'), ], 400); } @@ -50,7 +57,7 @@ class AuthenticateMiddleware extends AbstractRestMiddleware } catch (AuthenticationException $e) { return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => 'Invalid username and/or password', + 'message' => $this->translator->translate('Invalid username and/or password'), ], 401); } } diff --git a/src/Middleware/Rest/CreateShortcodeMiddleware.php b/module/Rest/src/Action/CreateShortcodeAction.php similarity index 64% rename from src/Middleware/Rest/CreateShortcodeMiddleware.php rename to module/Rest/src/Action/CreateShortcodeAction.php index f5ee3228..29aa1108 100644 --- a/src/Middleware/Rest/CreateShortcodeMiddleware.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -1,17 +1,18 @@ urlShortener = $urlShortener; + $this->translator = $translator; $this->domainConfig = $domainConfig; } @@ -48,7 +58,7 @@ class CreateShortcodeMiddleware extends AbstractRestMiddleware if (! isset($postData['longUrl'])) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => 'A URL was not provided', + 'message' => $this->translator->translate('A URL was not provided'), ], 400); } $longUrl = $postData['longUrl']; @@ -67,12 +77,15 @@ class CreateShortcodeMiddleware extends AbstractRestMiddleware } catch (InvalidUrlException $e) { return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl), + 'message' => sprintf( + $this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'), + $longUrl + ), ], 400); } catch (\Exception $e) { return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occured', + 'message' => $this->translator->translate('Unexpected error occurred'), ], 500); } } diff --git a/module/Rest/src/Action/GetVisitsAction.php b/module/Rest/src/Action/GetVisitsAction.php new file mode 100644 index 00000000..bd78adbb --- /dev/null +++ b/module/Rest/src/Action/GetVisitsAction.php @@ -0,0 +1,86 @@ +visitsTracker = $visitsTracker; + $this->translator = $translator; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + public function dispatch(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + $startDate = $this->getDateQueryParam($request, 'startDate'); + $endDate = $this->getDateQueryParam($request, 'endDate'); + + try { + $visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate)); + + return new JsonResponse([ + 'visits' => [ + 'data' => $visits, + ] + ]); + } catch (InvalidArgumentException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf($this->translator->translate('Provided short code "%s" is invalid'), $shortCode), + ], 400); + } catch (\Exception $e) { + return new JsonResponse([ + 'error' => RestUtils::UNKNOWN_ERROR, + 'message' => $this->translator->translate('Unexpected error occurred'), + ], 500); + } + } + + /** + * @param Request $request + * @param $key + * @return \DateTime|null + */ + protected function getDateQueryParam(Request $request, $key) + { + $query = $request->getQueryParams(); + if (! isset($query[$key]) || empty($query[$key])) { + return null; + } + + return new \DateTime($query[$key]); + } +} diff --git a/src/Middleware/Rest/ListShortcodesMiddleware.php b/module/Rest/src/Action/ListShortcodesAction.php similarity index 62% rename from src/Middleware/Rest/ListShortcodesMiddleware.php rename to module/Rest/src/Action/ListShortcodesAction.php index 99d82454..3d0d9613 100644 --- a/src/Middleware/Rest/ListShortcodesMiddleware.php +++ b/module/Rest/src/Action/ListShortcodesAction.php @@ -1,17 +1,17 @@ shortUrlService = $shortUrlService; + $this->translator = $translator; } /** @@ -46,7 +52,7 @@ class ListShortcodesMiddleware extends AbstractRestMiddleware } catch (\Exception $e) { return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occured', + 'message' => $this->translator->translate('Unexpected error occurred'), ], 500); } } diff --git a/src/Middleware/Rest/ResolveUrlMiddleware.php b/module/Rest/src/Action/ResolveUrlAction.php similarity index 59% rename from src/Middleware/Rest/ResolveUrlMiddleware.php rename to module/Rest/src/Action/ResolveUrlAction.php index 4529e973..c1783834 100644 --- a/src/Middleware/Rest/ResolveUrlMiddleware.php +++ b/module/Rest/src/Action/ResolveUrlAction.php @@ -1,31 +1,38 @@ urlShortener = $urlShortener; + $this->translator = $translator; } /** @@ -43,7 +50,7 @@ class ResolveUrlMiddleware extends AbstractRestMiddleware if (! isset($longUrl)) { return new JsonResponse([ 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => sprintf('No URL found for shortcode "%s"', $shortCode), + 'message' => sprintf($this->translator->translate('No URL found for shortcode "%s"'), $shortCode), ], 400); } @@ -53,12 +60,15 @@ class ResolveUrlMiddleware extends AbstractRestMiddleware } catch (InvalidShortCodeException $e) { return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided short code "%s" has an invalid format', $shortCode), + 'message' => sprintf( + $this->translator->translate('Provided short code "%s" has an invalid format'), + $shortCode + ), ], 400); } catch (\Exception $e) { return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occured', + 'message' => $this->translator->translate('Unexpected error occurred'), ], 500); } } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php new file mode 100644 index 00000000..0ad10a94 --- /dev/null +++ b/module/Rest/src/ConfigProvider.php @@ -0,0 +1,13 @@ +getAttribute(RouteResult::class) !== null; + $isNotFound = ! $hasRoute && ! isset($err); + if ($isNotFound) { + $responsePhrase = 'Not found'; + $status = 404; + } else { + $status = $response->getStatusCode(); + $responsePhrase = $status < 400 ? 'Internal Server Error' : $response->getReasonPhrase(); + $status = $status < 400 ? 500 : $status; + } + + return new JsonResponse([ + 'error' => $this->responsePhraseToCode($responsePhrase), + 'message' => $responsePhrase, + ], $status); + } + + /** + * @param string $responsePhrase + * @return string + */ + protected function responsePhraseToCode($responsePhrase) + { + return strtoupper(str_replace(' ', '_', $responsePhrase)); + } +} diff --git a/src/Exception/AuthenticationException.php b/module/Rest/src/Exception/AuthenticationException.php similarity index 74% rename from src/Exception/AuthenticationException.php rename to module/Rest/src/Exception/AuthenticationException.php index 0876be75..ec4d0a4b 100644 --- a/src/Exception/AuthenticationException.php +++ b/module/Rest/src/Exception/AuthenticationException.php @@ -1,5 +1,7 @@ restTokenService = $restTokenService; + $this->translator = $translator; } /** @@ -62,8 +69,10 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface // If current route is the authenticate route or an OPTIONS request, continue to the next middleware /** @var RouteResult $routeResult */ $routeResult = $request->getAttribute(RouteResult::class); - if ((isset($routeResult) && $routeResult->getMatchedRouteName() === 'rest-authenticate') - || strtolower($request->getMethod()) === 'options' + if (! isset($routeResult) + || $routeResult->isFailure() + || $routeResult->getMatchedRouteName() === 'rest-authenticate' + || $request->getMethod() === 'OPTIONS' ) { return $out($request, $response); } @@ -93,8 +102,10 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface return new JsonResponse([ 'error' => RestUtils::INVALID_AUTH_TOKEN_ERROR, 'message' => sprintf( - 'Missing or invalid auth token provided. Perform a new authentication request and send provided token ' - . 'on every new request on the "%s" header', + $this->translator->translate( + 'Missing or invalid auth token provided. Perform a new authentication request and send provided ' + . 'token on every new request on the "%s" header' + ), self::AUTH_TOKEN_HEADER ), ], 401); diff --git a/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php similarity index 63% rename from src/Middleware/CrossDomainMiddleware.php rename to module/Rest/src/Middleware/CrossDomainMiddleware.php index c76d4d73..3019badf 100644 --- a/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -1,5 +1,5 @@ getMethod()) === 'options') { - $response = $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ->withHeader('Access-Control-Max-Age', '1000') - ->withHeader( - // Allow all requested headers - 'Access-Control-Allow-Headers', - $request->getHeaderLine('Access-Control-Request-Headers') - ); + if (! $request->hasHeader('Origin')) { + return $response; } - return $response->withHeader('Access-Control-Allow-Origin', '*'); + // Add Allow-Origin header + $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin')); + if ($request->getMethod() !== '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-Max-Age' => '1000', + 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), + ] as $key => $value) { + $response = $response->withHeader($key, $value); + } + + return $response; } } diff --git a/src/Service/RestTokenService.php b/module/Rest/src/Service/RestTokenService.php similarity index 92% rename from src/Service/RestTokenService.php rename to module/Rest/src/Service/RestTokenService.php index 26d7f34c..b9dd4a9d 100644 --- a/src/Service/RestTokenService.php +++ b/module/Rest/src/Service/RestTokenService.php @@ -1,11 +1,11 @@ tokenService = $this->prophesize(RestTokenService::class); + $this->action = new AuthenticateAction($this->tokenService->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function notProvidingAuthDataReturnsError() + { + $resp = $this->action->__invoke(ServerRequestFactory::fromGlobals(), new Response()); + $this->assertEquals(400, $resp->getStatusCode()); + } + + /** + * @test + */ + public function properCredentialsReturnTokenInResponse() + { + $this->tokenService->createToken('foo', 'bar')->willReturn( + (new RestToken())->setToken('abc-ABC') + )->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'username' => 'foo', + 'password' => 'bar', + ]); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + + $response->getBody()->rewind(); + $this->assertEquals(['token' => 'abc-ABC'], json_decode($response->getBody()->getContents(), true)); + } + + /** + * @test + */ + public function authenticationExceptionsReturnErrorResponse() + { + $this->tokenService->createToken('foo', 'bar')->willThrow(new AuthenticationException()) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'username' => 'foo', + 'password' => 'bar', + ]); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(401, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/Action/CreateShortcodeActionTest.php b/module/Rest/test/Action/CreateShortcodeActionTest.php new file mode 100644 index 00000000..dcc56bf6 --- /dev/null +++ b/module/Rest/test/Action/CreateShortcodeActionTest.php @@ -0,0 +1,92 @@ +urlShortener = $this->prophesize(UrlShortener::class); + $this->action = new CreateShortcodeAction($this->urlShortener->reveal(), Translator::factory([]), [ + 'schema' => 'http', + 'hostname' => 'foo.com', + ]); + } + + /** + * @test + */ + public function missingLongUrlParamReturnsError() + { + $response = $this->action->__invoke(ServerRequestFactory::fromGlobals(), new Response()); + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function properShortcodeConversionReturnsData() + { + $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willReturn('abc123') + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'longUrl' => 'http://www.domain.com/foo/bar', + ]); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), 'http://foo.com/abc123') > 0); + } + + /** + * @test + */ + public function anInvalidUrlReturnsError() + { + $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(InvalidUrlException::class) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'longUrl' => 'http://www.domain.com/foo/bar', + ]); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_URL_ERROR) > 0); + } + + /** + * @test + */ + public function aGenericExceptionWillReturnError() + { + $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'longUrl' => 'http://www.domain.com/foo/bar', + ]); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0); + } +} diff --git a/module/Rest/test/Action/GetVisitsActionTest.php b/module/Rest/test/Action/GetVisitsActionTest.php new file mode 100644 index 00000000..901c549e --- /dev/null +++ b/module/Rest/test/Action/GetVisitsActionTest.php @@ -0,0 +1,99 @@ +visitsTracker = $this->prophesize(VisitsTracker::class); + $this->action = new GetVisitsAction($this->visitsTracker->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function providingCorrectShortCodeReturnsVisits() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willReturn([]) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function providingInvalidShortCodeReturnsError() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow( + InvalidArgumentException::class + )->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response() + ); + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function unexpectedExceptionWillReturnError() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow( + \Exception::class + )->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response() + ); + $this->assertEquals(500, $response->getStatusCode()); + } + + /** + * @test + */ + public function datesAreReadFromQuery() + { + $shortCode = 'abc123'; + $this->visitsTracker->info($shortCode, new DateRange(null, new \DateTime('2016-01-01 00:00:00'))) + ->willReturn([]) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode) + ->withQueryParams(['endDate' => '2016-01-01 00:00:00']), + new Response() + ); + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/Action/ListShortcodesActionTest.php b/module/Rest/test/Action/ListShortcodesActionTest.php new file mode 100644 index 00000000..b5ec0c9d --- /dev/null +++ b/module/Rest/test/Action/ListShortcodesActionTest.php @@ -0,0 +1,66 @@ +service = $this->prophesize(ShortUrlService::class); + $this->action = new ListShortcodesAction($this->service->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function properListReturnsSuccessResponse() + { + $page = 3; + $this->service->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withQueryParams([ + 'page' => $page, + ]), + new Response() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function anExceptionsReturnsErrorResponse() + { + $page = 3; + $this->service->listShortUrls($page)->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withQueryParams([ + 'page' => $page, + ]), + new Response() + ); + $this->assertEquals(500, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/Action/ResolveUrlActionTest.php b/module/Rest/test/Action/ResolveUrlActionTest.php new file mode 100644 index 00000000..0bd3bded --- /dev/null +++ b/module/Rest/test/Action/ResolveUrlActionTest.php @@ -0,0 +1,90 @@ +urlShortener = $this->prophesize(UrlShortener::class); + $this->action = new ResolveUrlAction($this->urlShortener->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function incorrectShortCodeReturnsError() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_ARGUMENT_ERROR) > 0); + } + + /** + * @test + */ + public function correctShortCodeReturnsSuccess() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn('http://domain.com/foo/bar') + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); + } + + /** + * @test + */ + public function invalidShortCodeExceptionReturnsError() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_SHORTCODE_ERROR) > 0); + } + + /** + * @test + */ + public function unexpectedExceptionWillReturnError() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); + $response = $this->action->__invoke($request, new Response()); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::UNKNOWN_ERROR) > 0); + } +} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php new file mode 100644 index 00000000..6801a82b --- /dev/null +++ b/module/Rest/test/ConfigProviderTest.php @@ -0,0 +1,33 @@ +configProvider = new ConfigProvider(); + } + + /** + * @test + */ + public function properConfigIsReturned() + { + $config = $this->configProvider->__invoke(); + + $this->assertArrayHasKey('error_handler', $config); + $this->assertArrayHasKey('middleware_pipeline', $config); + $this->assertArrayHasKey('rest', $config); + $this->assertArrayHasKey('routes', $config); + $this->assertArrayHasKey('dependencies', $config); + $this->assertArrayHasKey('translator', $config); + } +} diff --git a/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php b/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php new file mode 100644 index 00000000..ea1eee80 --- /dev/null +++ b/module/Rest/test/ErrorHandler/JsonErrorHandlerTest.php @@ -0,0 +1,79 @@ +errorHandler = new JsonErrorHandler(); + } + + /** + * @test + */ + public function noMatchedRouteReturnsNotFoundResponse() + { + $response = $this->errorHandler->__invoke(ServerRequestFactory::fromGlobals(), new Response()); + $this->assertInstanceOf(Response\JsonResponse::class, $response); + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function matchedRouteWithErrorReturnsMethodNotAllowedResponse() + { + $response = $this->errorHandler->__invoke( + ServerRequestFactory::fromGlobals(), + (new Response())->withStatus(405), + 405 + ); + $this->assertInstanceOf(Response\JsonResponse::class, $response); + $this->assertEquals(405, $response->getStatusCode()); + } + + /** + * @test + */ + public function responseWithErrorKeepsStatus() + { + $response = $this->errorHandler->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('foo', 'bar', []) + ), + (new Response())->withStatus(401), + 401 + ); + $this->assertInstanceOf(Response\JsonResponse::class, $response); + $this->assertEquals(401, $response->getStatusCode()); + } + + /** + * @test + */ + public function responseWithoutErrorReturnsStatus500() + { + $response = $this->errorHandler->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('foo', 'bar', []) + ), + (new Response())->withStatus(200), + 'Some error' + ); + $this->assertInstanceOf(Response\JsonResponse::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php new file mode 100644 index 00000000..650d4d2f --- /dev/null +++ b/module/Rest/test/Middleware/CheckAuthenticationMiddlewareTest.php @@ -0,0 +1,134 @@ +tokenService = $this->prophesize(RestTokenService::class); + $this->middleware = new CheckAuthenticationMiddleware($this->tokenService->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function someWhitelistedSituationsFallbackToNextMiddleware() + { + $request = ServerRequestFactory::fromGlobals(); + $response = new Response(); + $isCalled = false; + $this->assertFalse($isCalled); + $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) { + $isCalled = true; + }); + $this->assertTrue($isCalled); + + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteFailure(['GET']) + ); + $response = new Response(); + $isCalled = false; + $this->assertFalse($isCalled); + $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) { + $isCalled = true; + }); + $this->assertTrue($isCalled); + + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('rest-authenticate', 'foo', []) + ); + $response = new Response(); + $isCalled = false; + $this->assertFalse($isCalled); + $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) { + $isCalled = true; + }); + $this->assertTrue($isCalled); + + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('bar', 'foo', []) + )->withMethod('OPTIONS'); + $response = new Response(); + $isCalled = false; + $this->assertFalse($isCalled); + $this->middleware->__invoke($request, $response, function ($req, $resp) use (&$isCalled) { + $isCalled = true; + }); + $this->assertTrue($isCalled); + } + + /** + * @test + */ + public function noHeaderReturnsError() + { + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('bar', 'foo', []) + ); + $response = $this->middleware->__invoke($request, new Response()); + $this->assertEquals(401, $response->getStatusCode()); + } + + /** + * @test + */ + public function provideAnExpiredTokenReturnsError() + { + $authToken = 'ABC-abc'; + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('bar', 'foo', []) + )->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken); + $this->tokenService->getByToken($authToken)->willReturn( + (new RestToken())->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D'))) + )->shouldBeCalledTimes(1); + + $response = $this->middleware->__invoke($request, new Response()); + $this->assertEquals(401, $response->getStatusCode()); + } + + /** + * @test + */ + public function provideCorrectTokenUpdatesExpirationAndFallbacksToNextMiddleware() + { + $authToken = 'ABC-abc'; + $restToken = (new RestToken())->setExpirationDate((new \DateTime())->add(new \DateInterval('P1D'))); + $request = ServerRequestFactory::fromGlobals()->withAttribute( + RouteResult::class, + RouteResult::fromRouteMatch('bar', 'foo', []) + )->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken); + $this->tokenService->getByToken($authToken)->willReturn($restToken)->shouldBeCalledTimes(1); + $this->tokenService->updateExpiration($restToken)->shouldBeCalledTimes(1); + + $isCalled = false; + $this->assertFalse($isCalled); + $this->middleware->__invoke($request, new Response(), function ($req, $resp) use (&$isCalled) { + $isCalled = true; + }); + $this->assertTrue($isCalled); + } +} diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php new file mode 100644 index 00000000..780de834 --- /dev/null +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -0,0 +1,78 @@ +middleware = new CrossDomainMiddleware(); + } + + /** + * @test + */ + public function nonCrossDomainRequestsAreNotAffected() + { + $originalResponse = new Response(); + $response = $this->middleware->__invoke( + ServerRequestFactory::fromGlobals(), + $originalResponse, + function ($req, $resp) { + return $resp; + } + ); + $this->assertSame($originalResponse, $response); + + $headers = $response->getHeaders(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); + } + + /** + * @test + */ + public function anyRequestIncludesTheAllowAccessHeader() + { + $originalResponse = new Response(); + $response = $this->middleware->__invoke( + ServerRequestFactory::fromGlobals()->withHeader('Origin', 'local'), + $originalResponse, + function ($req, $resp) { + return $resp; + } + ); + $this->assertNotSame($originalResponse, $response); + + $headers = $response->getHeaders(); + $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); + $this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); + } + + /** + * @test + */ + public function optionsRequestIncludesMoreHeaders() + { + $originalResponse = new Response(); + $request = ServerRequestFactory::fromGlobals(['REQUEST_METHOD' => 'OPTIONS'])->withHeader('Origin', 'local'); + + $response = $this->middleware->__invoke($request, $originalResponse, function ($req, $resp) { + return $resp; + }); + $this->assertNotSame($originalResponse, $response); + + $headers = $response->getHeaders(); + $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); + $this->assertArrayHasKey('Access-Control-Allow-Headers', $headers); + } +} diff --git a/module/Rest/test/Service/RestTokenServiceTest.php b/module/Rest/test/Service/RestTokenServiceTest.php new file mode 100644 index 00000000..d4487ff1 --- /dev/null +++ b/module/Rest/test/Service/RestTokenServiceTest.php @@ -0,0 +1,93 @@ +em = $this->prophesize(EntityManager::class); + $this->service = new RestTokenService($this->em->reveal(), [ + 'username' => 'foo', + 'password' => 'bar', + ]); + } + + /** + * @test + */ + public function tokenIsCreatedIfCredentialsAreCorrect() + { + $this->em->persist(Argument::type(RestToken::class))->shouldBeCalledTimes(1); + $this->em->flush()->shouldBeCalledTimes(1); + + $token = $this->service->createToken('foo', 'bar'); + $this->assertInstanceOf(RestToken::class, $token); + $this->assertFalse($token->isExpired()); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException + */ + public function exceptionIsThrownWhileCreatingTokenWithWrongCredentials() + { + $this->service->createToken('foo', 'wrong'); + } + + /** + * @test + */ + public function restTokenIsReturnedFromTokenString() + { + $authToken = 'ABC-abc'; + $theToken = new RestToken(); + $repo = $this->prophesize(EntityRepository::class); + $repo->findOneBy(['token' => $authToken])->willReturn($theToken)->shouldBeCalledTimes(1); + $this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + + $this->assertSame($theToken, $this->service->getByToken($authToken)); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException + */ + public function exceptionIsThrownWhenRequestingWrongToken() + { + $authToken = 'ABC-abc'; + $repo = $this->prophesize(EntityRepository::class); + $repo->findOneBy(['token' => $authToken])->willReturn(null)->shouldBeCalledTimes(1); + $this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); + + $this->service->getByToken($authToken); + } + + /** + * @test + */ + public function updateExpirationFlushesEntityManager() + { + $token = $this->prophesize(RestToken::class); + $token->updateExpiration()->shouldBeCalledTimes(1); + $this->em->flush()->shouldBeCalledTimes(1); + + $this->service->updateExpiration($token->reveal()); + } +} diff --git a/module/Rest/test/Util/RestUtilsTest.php b/module/Rest/test/Util/RestUtilsTest.php new file mode 100644 index 00000000..d53b6905 --- /dev/null +++ b/module/Rest/test/Util/RestUtilsTest.php @@ -0,0 +1,40 @@ +assertEquals( + RestUtils::INVALID_SHORTCODE_ERROR, + RestUtils::getRestErrorCodeFromException(new InvalidShortCodeException()) + ); + $this->assertEquals( + RestUtils::INVALID_URL_ERROR, + RestUtils::getRestErrorCodeFromException(new InvalidUrlException()) + ); + $this->assertEquals( + RestUtils::INVALID_ARGUMENT_ERROR, + RestUtils::getRestErrorCodeFromException(new InvalidArgumentException()) + ); + $this->assertEquals( + RestUtils::INVALID_CREDENTIALS_ERROR, + RestUtils::getRestErrorCodeFromException(new AuthenticationException()) + ); + $this->assertEquals( + RestUtils::UNKNOWN_ERROR, + RestUtils::getRestErrorCodeFromException(new WrongIpException()) + ); + } +} diff --git a/phpcs.xml b/phpcs.xml index 51649659..ae134872 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,8 +16,7 @@ - src - tests + module config public/index.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0721b191..ddd4f42b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,13 +1,29 @@ - - ./tests + + ./module/Common/test + + + ./module/Core/test + + + ./module/Rest/test + + + ./module/CLI/test - src + ./module/Common/src + ./module/Core/src + ./module/Rest/src + ./module/CLI/src + + + ./module/Core/src/Repository + diff --git a/src/CLI/Command/GetVisitsCommand.php b/src/CLI/Command/GetVisitsCommand.php deleted file mode 100644 index 3c4e796d..00000000 --- a/src/CLI/Command/GetVisitsCommand.php +++ /dev/null @@ -1,77 +0,0 @@ -visitsTracker = $visitsTracker; - } - - public function configure() - { - $this->setName('shortcode:visits') - ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get'); - } - - public function interact(InputInterface $input, OutputInterface $output) - { - $shortCode = $input->getArgument('shortCode'); - if (! empty($shortCode)) { - return; - } - - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question( - 'A short code was not provided. Which short code do you want to use?: ' - ); - - $shortCode = $helper->ask($input, $output, $question); - if (! empty($shortCode)) { - $input->setArgument('shortCode', $shortCode); - } - } - - public function execute(InputInterface $input, OutputInterface $output) - { - $shortCode = $input->getArgument('shortCode'); - $visits = $this->visitsTracker->info($shortCode); - $table = new Table($output); - $table->setHeaders([ - 'Referer', - 'Date', - 'Temote Address', - 'User agent', - ]); - - foreach ($visits as $row) { - $table->addRow(array_values($row->jsonSerialize())); - } - $table->render(); - } -} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php deleted file mode 100644 index d6bdc788..00000000 --- a/src/Exception/ExceptionInterface.php +++ /dev/null @@ -1,6 +0,0 @@ -visitsTracker = $visitsTracker; - } - - /** - * @param Request $request - * @param Response $response - * @param callable|null $out - * @return null|Response - */ - public function dispatch(Request $request, Response $response, callable $out = null) - { - $shortCode = $request->getAttribute('shortCode'); - - try { - $visits = $this->visitsTracker->info($shortCode); - - return new JsonResponse([ - 'visits' => [ - 'data' => $visits, -// 'pagination' => [], - ] - ]); - } catch (InvalidArgumentException $e) { - return new JsonResponse([ - 'error' => RestUtils::getRestErrorCodeFromException($e), - 'message' => sprintf('Provided short code "%s" is invalid', $shortCode), - ], 400); - } catch (\Exception $e) { - return new JsonResponse([ - 'error' => RestUtils::UNKNOWN_ERROR, - 'message' => 'Unexpected error occured', - ], 500); - } - } -} diff --git a/templates/error/404.html.twig b/templates/error/404.html.twig deleted file mode 100644 index 0e591e2a..00000000 --- a/templates/error/404.html.twig +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'layout/default.html.twig' %} - -{% block title %}URL Not Found{% endblock %} - -{% block stylesheets %} - -{% endblock %} - -{% block content %} -

Oops!

-
-

This short URL doesn't seem to be valid.

-

Make sure you included all the characters, with no extra punctuation.

-{% endblock %} diff --git a/templates/error/error.html.twig b/templates/error/error.html.twig deleted file mode 100644 index cd54354e..00000000 --- a/templates/error/error.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'layout/default.html.twig' %} - -{% block title %}{{ status }} {{ reason }}{% endblock %} - -{% block stylesheets %} - -{% endblock %} - -{% block content %} -

Oops!

-
-

We encountered a {{ status }} {{ reason }} error.

- {% if status == 404 %} -

This short URL doesn't seem to be valid.

-

Make sure you included all the characters, with no extra punctuation.

- {% endif %} -{% endblock %} diff --git a/tests/Factory/CacheFactoryTest.php b/tests/Factory/CacheFactoryTest.php deleted file mode 100644 index a6920d56..00000000 --- a/tests/Factory/CacheFactoryTest.php +++ /dev/null @@ -1,46 +0,0 @@ -factory = new CacheFactory(); - } - - public static function tearDownAfterClass() - { - putenv('APP_ENV'); - } - - /** - * @test - */ - public function productionReturnsApcAdapter() - { - putenv('APP_ENV=pro'); - $instance = $this->factory->__invoke(new ServiceManager(), ''); - $this->assertInstanceOf(ApcuCache::class, $instance); - } - - /** - * @test - */ - public function developmentReturnsArrayAdapter() - { - putenv('APP_ENV=dev'); - $instance = $this->factory->__invoke(new ServiceManager(), ''); - $this->assertInstanceOf(ArrayCache::class, $instance); - } -} diff --git a/tests/Service/VisitsTrackerTest.php b/tests/Service/VisitsTrackerTest.php deleted file mode 100644 index 7e6707c7..00000000 --- a/tests/Service/VisitsTrackerTest.php +++ /dev/null @@ -1,30 +0,0 @@ -prophesize(EntityRepository::class); - $repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl()); - - $em = $this->prophesize(EntityManager::class); - $em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); - $em->persist(Argument::any())->shouldBeCalledTimes(1); - $em->flush()->shouldBeCalledTimes(1); - - $visitsTracker = new VisitsTracker($em->reveal()); - $visitsTracker->track($shortCode); - } -}