Merge pull request #97 from acelaya/feature/1.5

Feature/1.5
This commit is contained in:
Alejandro Celaya 2017-07-16 10:01:50 +02:00 committed by GitHub
commit 9c8eef12ba
94 changed files with 3416 additions and 414 deletions

View File

@ -1,7 +1,8 @@
<?php
namespace PHPSTORM_META;
use Interop\Container\ContainerInterface;
use Psr\Container\ContainerInterface;
use Zend\ServiceManager\ServiceManager;
/**
* PhpStorm Container Interop code completion
@ -16,4 +17,7 @@ $STATIC_METHOD_TYPES = [
ContainerInterface::get('') => [
'' == '@',
],
ServiceManager::build('') => [
'' == '@',
],
];

View File

@ -1,5 +1,23 @@
## CHANGELOG
### 1.5.0
**Enhancements:**
* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95)
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59)
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
**Tasks**
* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96)
* [76: Add response examples to swagger docs](https://github.com/shlinkio/shlink/issues/76)
* [93: Improve cross domain management by using the ImplicitOptionsMiddleware](https://github.com/shlinkio/shlink/issues/93)
**Bugs**
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92)
### 1.4.0
**Enhancements:**

View File

@ -5,7 +5,4 @@ use Symfony\Component\Console\Application as CliApp;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
/** @var CliApp $app */
$app = $container->get(CliApp::class);
$app->run();
$container->get(CliApp::class)->run();

View File

@ -1,14 +1,19 @@
#!/usr/bin/env php
<?php
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Zend\Config\Writer\PhpArray;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$app = new Application();
$app->add(new InstallCommand(new PhpArray()));
$app->setDefaultCommand('shlink:install');
$app->run();
$container = new ServiceManager(['factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
QuestionHelper::class => InvokableFactory::class,
]]);
$container->build(Application::class)->run();

View File

@ -1,14 +1,19 @@
#!/usr/bin/env php
<?php
use Shlinkio\Shlink\CLI\Command\Install\UpdateCommand;
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Zend\Config\Writer\PhpArray;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$app = new Application();
$app->add(new UpdateCommand(new PhpArray()));
$app->setDefaultCommand('shlink:install');
$app->run();
$container = new ServiceManager(['factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
QuestionHelper::class => InvokableFactory::class,
]]);
$container->build(Application::class, ['isUpdate' => true])->run();

View File

@ -8,7 +8,7 @@ if [ "$#" -ne 1 ]; then
fi
version=$1
builtcontent=$(readlink -f '../shlink_build_tmp')
builtcontent=$(readlink -f "../shlink_${version}_dist")
projectdir=$(pwd)
# Copy project content to temp dir
@ -31,6 +31,8 @@ rm build.sh
rm CHANGELOG.md
rm composer.*
rm LICENSE
rm indocker
rm docker-compose.yml
rm php*
rm README.md
rm -r build
@ -42,5 +44,5 @@ rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Compressing file
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip .
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
rm -rf "${builtcontent}"

View File

@ -42,7 +42,8 @@
"roave/security-advisories": "dev-master",
"filp/whoops": "^2.0",
"symfony/var-dumper": "^3.0",
"vlucas/phpdotenv": "^2.2"
"vlucas/phpdotenv": "^2.2",
"zendframework/zend-expressive-tooling": "^0.4"
},
"autoload": {
"psr-4": {

View File

@ -1,10 +1,12 @@
<?php
use Shlinkio\Shlink\Common;
return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.2.0',
'secret_key' => env('SECRET_KEY'),
'secret_key' => Common\env('SECRET_KEY'),
],
];

View File

@ -1,6 +1,8 @@
<?php
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Middleware;
use Zend\Expressive\Router;
use Zend\Expressive\Template;
use Zend\Expressive\Twig;
@ -15,6 +17,7 @@ return [
\Twig_Environment::class => Twig\TwigEnvironmentFactory::class,
Router\RouterInterface::class => Router\FastRouteRouterFactory::class,
ErrorHandler::class => Container\ErrorHandlerFactory::class,
Middleware\ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
],
],

View File

@ -1,4 +1,6 @@
<?php
use Shlinkio\Shlink\Common;
return [
'entity_manager' => [
@ -6,9 +8,9 @@ return [
'proxies_dir' => 'data/proxies',
],
'connection' => [
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'dbname' => env('DB_NAME', 'shlink'),
'user' => Common\env('DB_USER'),
'password' => Common\env('DB_PASSWORD'),
'dbname' => Common\env('DB_NAME', 'shlink'),
'charset' => 'utf8',
],
],

View File

@ -4,7 +4,7 @@ use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
use Shlinkio\Shlink\Rest\Middleware\PathVersionMiddleware;
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive;
use Zend\Stratigility\Middleware\ErrorHandler;
return [
@ -27,7 +27,7 @@ return [
'routing' => [
'middleware' => [
ApplicationFactory::ROUTING_MIDDLEWARE,
Expressive\Application::ROUTING_MIDDLEWARE,
],
'priority' => 10,
],
@ -36,6 +36,7 @@ return [
'path' => '/rest',
'middleware' => [
CrossDomainMiddleware::class,
Expressive\Middleware\ImplicitOptionsMiddleware::class,
BodyParserMiddleware::class,
CheckAuthenticationMiddleware::class,
],
@ -44,7 +45,7 @@ return [
'post-routing' => [
'middleware' => [
ApplicationFactory::DISPATCH_MIDDLEWARE,
Expressive\Application::DISPATCH_MIDDLEWARE,
],
'priority' => 1,
],

View File

@ -1,8 +1,10 @@
<?php
use Shlinkio\Shlink\Common;
return [
'translator' => [
'locale' => env('DEFAULT_LOCALE', 'en'),
'locale' => Common\env('DEFAULT_LOCALE', 'en'),
],
];

View File

@ -1,14 +1,15 @@
<?php
use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core\Service\UrlShortener;
return [
'url_shortener' => [
'domain' => [
'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => env('SHORTENED_URL_HOSTNAME'),
'schema' => Common\env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => Common\env('SHORTENED_URL_HOSTNAME'),
],
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
'shortcode_chars' => Common\env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
],
];

View File

@ -25,6 +25,11 @@
"description": "The authentication token that needs to be sent in the Authorization header"
}
}
},
"examples": {
"application/json": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"
}
}
},
"400": {

View File

@ -68,6 +68,44 @@
}
}
}
},
"examples": {
"application/json": {
"shortUrls": {
"data": [
{
"shortCode": "12C18",
"originalUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"tags": [
"games",
"tech"
]
},
{
"shortCode": "12Kb3",
"originalUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
]
},
{
"shortCode": "123bA",
"originalUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": []
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12
}
}
}
}
},
"500": {

View File

@ -28,6 +28,11 @@
"description": "The original long URL behind the short code."
}
}
},
"examples": {
"application/json": {
"longUrl": "https://shlink.io"
}
}
},
"400": {

View File

@ -41,6 +41,14 @@
}
}
}
},
"examples": {
"application/json": {
"tags": [
"games",
"tech"
]
}
}
},
"400": {

View File

@ -36,6 +36,32 @@
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "10.20.30.40",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "11.22.33.44",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "110.220.5.6",
"userAgent": "some_web_crawler/1.4"
}
]
}
}
}
},
"404": {

View File

@ -0,0 +1,193 @@
{
"get": {
"tags": [
"Tags"
],
"summary": "List existing tags",
"description": "Returns the list of all tags used in any short URL, ordered by name",
"parameters": [
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "The list of tags",
"schema": {
"type": "object",
"properties": {
"tags": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"examples": {
"application/json": {
"tags": {
"data": [
"games",
"php",
"shlink",
"tech"
]
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"post": {
"tags": [
"Tags"
],
"summary": "Create tags",
"description": "Provided a list of tags, creates all that do not yet exist",
"parameters": [
{
"$ref": "../parameters/Authorization.json"
},
{
"name": "tags[]",
"in": "formData",
"description": "The list of tag names to create",
"required": true,
"type": "array"
}
],
"responses": {
"200": {
"description": "The list of tags",
"schema": {
"type": "object",
"properties": {
"tags": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"examples": {
"application/json": {
"tags": {
"data": [
"games",
"php",
"shlink",
"tech"
]
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"put": {
"tags": [
"Tags"
],
"summary": "Rename tag",
"description": "Renames one existing tag",
"parameters": [
{
"$ref": "../parameters/Authorization.json"
},
{
"name": "oldName",
"in": "formData",
"description": "Current name of the tag",
"required": true,
"type": "string"
},
{
"name": "newName",
"in": "formData",
"description": "New name of the tag",
"required": true,
"type": "string"
}
],
"responses": {
"204": {
"description": "The tag has been properly renamed"
},
"400": {
"description": "You have not provided either the oldName or the newName params.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"404": {
"description": "There's no tag found with the name provided in oldName param.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"delete": {
"tags": [
"Tags"
],
"summary": "Delete tags",
"description": "Deletes provided list of tags",
"parameters": [
{
"$ref": "../parameters/Authorization.json"
},
{
"name": "tags[]",
"in": "query",
"description": "The names of the tags to delete",
"required": true,
"type": "array"
}
],
"responses": {
"204": {
"description": "Tags properly deleted"
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
"version": "1.2.0"
"version": "1.0"
},
"schemes": [
"http",
@ -22,17 +22,23 @@
"/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
},
"/v1/short-codes": {
"$ref": "paths/v1_short-codes.json"
},
"/v1/short-codes/{shortCode}": {
"$ref": "paths/v1_short-codes_{shortCode}.json"
},
"/v1/short-codes/{shortCode}/visits": {
"$ref": "paths/v1_short-codes_{shortCode}_visits.json"
},
"/v1/short-codes/{shortCode}/tags": {
"$ref": "paths/v1_short-codes_{shortCode}_tags.json"
},
"/v1/tags": {
"$ref": "paths/v1_tags.json"
},
"/v1/short-codes/{shortCode}/visits": {
"$ref": "paths/v1_short-codes_{shortCode}_visits.json"
}
}
}

View File

@ -1,10 +1,11 @@
<?php
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\Common;
return [
'cli' => [
'locale' => env('CLI_LOCALE', 'en'),
'locale' => Common\env('CLI_LOCALE', 'en'),
'commands' => [
Command\Shortcode\GenerateShortcodeCommand::class,
Command\Shortcode\ResolveUrlCommand::class,
@ -17,6 +18,10 @@ return [
Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::class,
Command\Tag\ListTagsCommand::class,
Command\Tag\CreateTagCommand::class,
Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::class,
]
],

View File

@ -21,6 +21,10 @@ return [
Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class,
Command\Api\DisableKeyCommand::class => AnnotatedFactory::class,
Command\Api\ListKeysCommand::class => AnnotatedFactory::class,
Command\Tag\ListTagsCommand::class => AnnotatedFactory::class,
Command\Tag\CreateTagCommand::class => AnnotatedFactory::class,
Command\Tag\RenameTagCommand::class => AnnotatedFactory::class,
Command\Tag\DeleteTagsCommand::class => AnnotatedFactory::class,
],
],

Binary file not shown.

View File

@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-10-22 23:12+0200\n"
"PO-Revision-Date: 2016-10-22 23:13+0200\n"
"POT-Creation-Date: 2017-07-16 09:35+0200\n"
"PO-Revision-Date: 2017-07-16 09:39+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.7.1\n"
"X-Generator: Poedit 2.0.1\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@ -223,6 +223,53 @@ msgstr "URL larga:"
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas."
msgid "The name of the tags to create"
msgstr "El nombre de las etiquetas a crear"
msgid "You have to provide at least one tag name"
msgstr "Debes proporcionar al menos un nombre de etiqueta"
msgid "Created tags"
msgstr "Etiquetas creadas"
msgid "Deletes one or more tags."
msgstr "Elimina una o más etiquetas."
msgid "The name of the tags to delete"
msgstr "El nombre de las etiquetas a eliminar"
msgid "Deleted tags"
msgstr "Etiquetas eliminadas"
msgid "Lists existing tags."
msgstr "Lista las etiquetas existentes."
#, fuzzy
msgid "Name"
msgstr "Nombre"
msgid "No tags yet"
msgstr "Aún no hay etiquetas"
msgid "Renames one existing tag."
msgstr "Renombra una etiqueta existente."
msgid "Current name of the tag."
msgstr "Nombre actual de la etiqueta."
msgid "New name of the tag."
msgstr "Nuevo nombre de la etiqueta."
msgid "Tag properly renamed."
msgstr "Etiqueta correctamente renombrada."
#, php-format
msgid "A tag with name \"%s\" was not found"
msgstr "Una etiqueta con nombre \"%s\" no ha sido encontrada"
msgid "Processes visits where location is not set yet"
msgstr "Procesa las visitas donde la localización no ha sido establecida aún"

View File

@ -32,7 +32,7 @@ class DisableKeyCommand extends Command
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct(null);
parent::__construct();
}
public function configure()

View File

@ -84,7 +84,7 @@ class ListKeysCommand extends Command
$rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row));
}
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ISO8601) : '-';
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ATOM) : '-';
$table->addRow($rowData);
}

View File

@ -1,27 +1,25 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Install;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface;
use Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
{
use StringUtilsTrait;
const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
const SUPPORTED_LANGUAGES = ['en', 'es'];
const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/**
* @var InputInterface
@ -43,22 +41,44 @@ class InstallCommand extends Command
* @var WriterInterface
*/
private $configWriter;
/**
* @var Filesystem
*/
private $filesystem;
/**
* @var ConfigCustomizerPluginManagerInterface
*/
private $configCustomizers;
/**
* @var bool
*/
private $isUpdate;
/**
* InstallCommand constructor.
* @param WriterInterface $configWriter
* @param callable|null $databaseCreationLogic
* @param Filesystem $filesystem
* @param bool $isUpdate
* @throws LogicException
*/
public function __construct(WriterInterface $configWriter)
{
parent::__construct(null);
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerPluginManagerInterface $configCustomizers,
$isUpdate = false
) {
parent::__construct();
$this->configWriter = $configWriter;
$this->isUpdate = $isUpdate;
$this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers;
}
public function configure()
{
$this->setName('shlink:install')
->setDescription('Installs Shlink');
$this
->setName('shlink:install')
->setDescription('Installs or updates Shlink');
}
public function execute(InputInterface $input, OutputInterface $output)
@ -67,40 +87,58 @@ class InstallCommand extends Command
$this->output = $output;
$this->questionHelper = $this->getHelper('question');
$this->processHelper = $this->getHelper('process');
$params = [];
$output->writeln([
'<info>Welcome to Shlink!!</info>',
'This process will guide you through the installation.',
'This will guide you through the installation process.',
]);
// Check if a cached config file exists and drop it if so
if (file_exists('data/cache/app_config.php')) {
if ($this->filesystem->exists('data/cache/app_config.php')) {
$output->write('Deleting old cached config...');
if (unlink('data/cache/app_config.php')) {
try {
$this->filesystem->remove('data/cache/app_config.php');
$output->writeln(' <info>Success</info>');
} else {
} catch (IOException $e) {
$output->writeln(
' <error>Failed!</error> You will have to manually delete the data/cache/app_config.php file to get'
. ' new config applied.'
);
if ($output->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return;
}
}
// If running update command, ask the user to import previous config
$config = $this->isUpdate ? $this->importConfig() : new CustomizableAppConfig();
// Ask for custom config params
$params['DATABASE'] = $this->askDatabase();
$params['URL_SHORTENER'] = $this->askUrlShortener();
$params['LANGUAGE'] = $this->askLanguage();
$params['APP'] = $this->askApplication();
foreach ([
Plugin\DatabaseConfigCustomizerPlugin::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class,
Plugin\LanguageConfigCustomizerPlugin::class,
Plugin\ApplicationConfigCustomizerPlugin::class,
] as $pluginName) {
/** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */
$configCustomizer = $this->configCustomizers->get($pluginName);
$configCustomizer->process($input, $output, $config);
}
// Generate config params files
$config = $this->buildAppConfig($params);
$this->configWriter->toFile('config/params/generated_config.php', $config, false);
$this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false);
$output->writeln(['<info>Custom configuration properly generated!</info>', '']);
// Generate database
if (! $this->createDatabase()) {
return;
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->output->writeln('Initializing database...');
if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:schema-tool:create',
'Error generating database.'
)) {
return;
}
}
// Run database migrations
@ -116,105 +154,47 @@ class InstallCommand extends Command
}
}
protected function askDatabase()
/**
* @return CustomizableAppConfig
* @throws RuntimeException
*/
private function importConfig()
{
$params = [];
$this->printTitle('DATABASE');
$config = new CustomizableAppConfig();
// Select database type
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select database type (defaults to ' . $databases[0] . '):</question>',
$databases,
0
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(
'<question>Do you want to import previous configuration? (Y/n):</question> '
));
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $this->ask('Database name', 'shlink');
$params['USER'] = $this->ask('Database username');
$params['PASSWORD'] = $this->ask('Database password');
$params['HOST'] = $this->ask('Database host', 'localhost');
$params['PORT'] = $this->ask('Database port', $this->getDefaultDbPort($params['DRIVER']));
if (! $importConfig) {
return $config;
}
return $params;
}
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->ask(
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;
$configExists = $this->filesystem->exists($configFile);
protected function getDefaultDbPort($driver)
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
if (! $configExists) {
$keepAsking = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(
'Provided path does not seem to be a valid shlink root path. '
. '<question>Do you want to try another path? (Y/n):</question> '
));
}
} while (! $configExists && $keepAsking);
protected function askUrlShortener()
{
$this->printTitle('URL SHORTENER');
// If after some retries the user has chosen not to test another path, return
if (! $configExists) {
return $config;
}
// Ask for URL shortener params
return [
'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select schema for generated short URLs (defaults to http):</question>',
['http', 'https'],
0
)),
'HOSTNAME' => $this->ask('Hostname for generated URLs'),
'CHARS' => $this->ask(
'Character set for generated short codes (leave empty to autogenerate one)',
null,
true
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS)
];
}
protected function askLanguage()
{
$this->printTitle('LANGUAGE');
return [
'DEFAULT' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select default language for the application in general (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
'CLI' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select default language for CLI executions (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
];
}
protected function askApplication()
{
$this->printTitle('APPLICATION');
return [
'SECRET' => $this->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
true
) ?: $this->generateRandomString(32),
];
}
/**
* @param string $text
*/
protected function printTitle($text)
{
$text = trim($text);
$length = strlen($text) + 4;
$header = str_repeat('*', $length);
$this->output->writeln([
'',
'<info>' . $header . '</info>',
'<info>* ' . strtoupper($text) . ' *</info>',
'<info>' . $header . '</info>',
]);
// Read the config file
$config->exchangeArray(include $configFile);
return $config;
}
/**
@ -222,10 +202,11 @@ class InstallCommand extends Command
* @param string|null $default
* @param bool $allowEmpty
* @return string
* @throws RuntimeException
*/
protected function ask($text, $default = null, $allowEmpty = false)
private function ask($text, $default = null, $allowEmpty = false)
{
if (isset($default)) {
if ($default !== null) {
$text .= ' (defaults to ' . $default . ')';
}
do {
@ -236,87 +217,31 @@ class InstallCommand extends Command
if (empty($value) && ! $allowEmpty) {
$this->output->writeln('<error>Value can\'t be empty</error>');
}
} while (empty($value) && empty($default) && ! $allowEmpty);
} while (empty($value) && $default === null && ! $allowEmpty);
return $value;
}
/**
* @param array $params
* @return array
*/
protected function buildAppConfig(array $params)
{
// Build simple config
$config = [
'app_options' => [
'secret_key' => $params['APP']['SECRET'],
],
'entity_manager' => [
'connection' => [
'driver' => $params['DATABASE']['DRIVER'],
],
],
'translator' => [
'locale' => $params['LANGUAGE']['DEFAULT'],
],
'cli' => [
'locale' => $params['LANGUAGE']['CLI'],
],
'url_shortener' => [
'domain' => [
'schema' => $params['URL_SHORTENER']['SCHEMA'],
'hostname' => $params['URL_SHORTENER']['HOSTNAME'],
],
'shortcode_chars' => $params['URL_SHORTENER']['CHARS'],
],
];
// Build dynamic database config
if ($params['DATABASE']['DRIVER'] === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = 'data/database.sqlite';
} else {
$config['entity_manager']['connection']['user'] = $params['DATABASE']['USER'];
$config['entity_manager']['connection']['password'] = $params['DATABASE']['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $params['DATABASE']['NAME'];
$config['entity_manager']['connection']['host'] = $params['DATABASE']['HOST'];
$config['entity_manager']['connection']['port'] = $params['DATABASE']['PORT'];
if ($params['DATABASE']['DRIVER'] === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
protected function createDatabase()
{
$this->output->writeln('Initializing database...');
return $this->runCommand('php vendor/bin/doctrine.php orm:schema-tool:create', 'Error generating database.');
}
/**
* @param string $command
* @param string $errorMessage
* @return bool
*/
protected function runCommand($command, $errorMessage)
private function runCommand($command, $errorMessage)
{
$process = $this->processHelper->run($this->output, $command);
if ($process->isSuccessful()) {
$this->output->writeln(' <info>Success!</info>');
return true;
} else {
if ($this->output->isVerbose()) {
return false;
}
$this->output->writeln(
' <error>' . $errorMessage . '</error> Run this command with -vvv to see specific error info.'
);
}
if ($this->output->isVerbose()) {
return false;
}
$this->output->writeln(
' <error>' . $errorMessage . '</error> Run this command with -vvv to see specific error info.'
);
return false;
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Install;
use Zend\Config\Writer\WriterInterface;
class UpdateCommand extends InstallCommand
{
public function createDatabase()
{
return true;
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\Translator;
use Zend\I18n\Translator\TranslatorInterface;
class CreateTagCommand extends Command
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* CreateTagCommand constructor.
* @param TagServiceInterface $tagService
* @param TranslatorInterface $translator
*
* @DI\Inject({TagService::class, Translator::class})
*/
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
}
protected function configure()
{
$this
->setName('tag:create')
->setDescription($this->translator->translate('Creates one or more tags.'))
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$this->translator->translate('The name of the tags to create')
);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$output->writeln(sprintf(
'<comment>%s</comment>',
$this->translator->translate('You have to provide at least one tag name')
));
return;
}
$this->tagService->createTags($tagNames);
$output->writeln($this->translator->translate('Created tags') . sprintf(': ["<info>%s</info>"]', implode(
'</info>", "<info>',
$tagNames
)));
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\Translator;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteTagsCommand extends Command
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ListTagsCommand constructor.
* @param TagServiceInterface $tagService
* @param TranslatorInterface $translator
*
* @DI\Inject({TagService::class, Translator::class})
*/
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
}
protected function configure()
{
$this
->setName('tag:delete')
->setDescription($this->translator->translate('Deletes one or more tags.'))
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$this->translator->translate('The name of the tags to delete')
);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$output->writeln(sprintf(
'<comment>%s</comment>',
$this->translator->translate('You have to provide at least one tag name')
));
return;
}
$this->tagService->deleteTags($tagNames);
$output->writeln($this->translator->translate('Deleted tags') . sprintf(': ["<info>%s</info>"]', implode(
'</info>", "<info>',
$tagNames
)));
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\Translator;
use Zend\I18n\Translator\TranslatorInterface;
class ListTagsCommand extends Command
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ListTagsCommand constructor.
* @param TagServiceInterface $tagService
* @param TranslatorInterface $translator
*
* @DI\Inject({TagService::class, Translator::class})
*/
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
}
protected function configure()
{
$this
->setName('tag:list')
->setDescription($this->translator->translate('Lists existing tags.'));
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$table = new Table($output);
$table->setHeaders([$this->translator->translate('Name')])
->setRows($this->getTagsRows());
$table->render();
}
private function getTagsRows()
{
$tags = $this->tagService->listTags();
if (empty($tags)) {
return [[$this->translator->translate('No tags yet')]];
}
return array_map(function (Tag $tag) {
return [$tag->getName()];
}, $tags);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\Translator;
use Zend\I18n\Translator\TranslatorInterface;
class RenameTagCommand extends Command
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* RenameTagCommand constructor.
* @param TagServiceInterface $tagService
* @param TranslatorInterface $translator
*
* @DI\Inject({TagService::class, Translator::class})
*/
public function __construct(TagServiceInterface $tagService, TranslatorInterface $translator)
{
$this->tagService = $tagService;
$this->translator = $translator;
parent::__construct();
}
protected function configure()
{
$this
->setName('tag:rename')
->setDescription($this->translator->translate('Renames one existing tag.'))
->addArgument('oldName', InputArgument::REQUIRED, $this->translator->translate('Current name of the tag.'))
->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.'));
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
try {
$this->tagService->renameTag($oldName, $newName);
$output->writeln(sprintf('<info>%s</info>', $this->translator->translate('Tag properly renamed.')));
} catch (EntityDoesNotExistException $e) {
$output->writeln('<error>' . sprintf($this->translator->translate(
'A tag with name "%s" was not found'
), $oldName) . '</error>');
}
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Shlinkio\Shlink\CLI\Factory;
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManager;
use Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\PhpArray;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class InstallApplicationFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws LogicException
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$isUpdate = $options !== null && isset($options['isUpdate']) ? (bool) $options['isUpdate'] : false;
$app = new Application();
$command = new InstallCommand(
new PhpArray(),
$container->get(Filesystem::class),
new ConfigCustomizerPluginManager($container, ['factories' => [
Plugin\DatabaseConfigCustomizerPlugin::class => AnnotatedFactory::class,
Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class,
]]),
$isUpdate
);
$app->add($command);
$app->setDefaultCommand($command->getName());
return $app;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Shlinkio\Shlink\CLI\Install;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerPluginInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerPluginManager extends AbstractPluginManager implements ConfigCustomizerPluginManagerInterface
{
protected $instanceOf = ConfigCustomizerPluginInterface::class;
}

View File

@ -0,0 +1,8 @@
<?php
namespace Shlinkio\Shlink\CLI\Install;
use Psr\Container\ContainerInterface;
interface ConfigCustomizerPluginManagerInterface extends ContainerInterface
{
}

View File

@ -0,0 +1,66 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
abstract class AbstractConfigCustomizerPlugin implements ConfigCustomizerPluginInterface
{
/**
* @var QuestionHelper
*/
protected $questionHelper;
public function __construct(QuestionHelper $questionHelper)
{
$this->questionHelper = $questionHelper;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param string $text
* @param string|null $default
* @param bool $allowEmpty
* @return string
* @throws RuntimeException
*/
protected function ask(InputInterface $input, OutputInterface $output, $text, $default = null, $allowEmpty = false)
{
if ($default !== null) {
$text .= ' (defaults to ' . $default . ')';
}
do {
$value = $this->questionHelper->ask($input, $output, new Question(
'<question>' . $text . ':</question> ',
$default
));
if (empty($value) && ! $allowEmpty) {
$output->writeln('<error>Value can\'t be empty</error>');
}
} while (empty($value) && $default === null && ! $allowEmpty);
return $value;
}
/**
* @param OutputInterface $output
* @param string $text
*/
protected function printTitle(OutputInterface $output, $text)
{
$text = trim($text);
$length = strlen($text) + 4;
$header = str_repeat('*', $length);
$output->writeln([
'',
'<info>' . $header . '</info>',
'<info>* ' . strtoupper($text) . ' *</info>',
'<info>' . $header . '</info>',
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class ApplicationConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
use StringUtilsTrait;
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws \Symfony\Component\Console\Exception\RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'APPLICATION');
if ($appConfig->hasApp() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported application config? (Y/n):</question> '
))) {
return;
}
$appConfig->setApp([
'SECRET' => $this->ask(
$input,
$output,
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
true
) ?: $this->generateRandomString(32),
]);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
interface ConfigCustomizerPluginInterface
{
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig);
}

View File

@ -0,0 +1,98 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/**
* @var Filesystem
*/
private $filesystem;
/**
* DatabaseConfigCustomizerPlugin constructor.
* @param QuestionHelper $questionHelper
* @param Filesystem $filesystem
*
* @DI\Inject({QuestionHelper::class, Filesystem::class})
*/
public function __construct(QuestionHelper $questionHelper, Filesystem $filesystem)
{
parent::__construct($questionHelper);
$this->filesystem = $filesystem;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws IOException
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'DATABASE');
if ($appConfig->hasDatabase() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported database config? (Y/n):</question> '
))) {
// If the user selected to keep DB config and is configured to use sqlite, copy DB file
if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) {
try {
$this->filesystem->copy(
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
CustomizableAppConfig::SQLITE_DB_PATH
);
} catch (IOException $e) {
$output->writeln('<error>It wasn\'t possible to import the SQLite database</error>');
throw $e;
}
}
return;
}
// Select database type
$params = [];
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select database type (defaults to ' . $databases[0] . '):</question>',
$databases,
0
));
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $this->ask($input, $output, 'Database name', 'shlink');
$params['USER'] = $this->ask($input, $output, 'Database username');
$params['PASSWORD'] = $this->ask($input, $output, 'Database password');
$params['HOST'] = $this->ask($input, $output, 'Database host', 'localhost');
$params['PORT'] = $this->ask($input, $output, 'Database port', $this->getDefaultDbPort($params['DRIVER']));
}
$appConfig->setDatabase($params);
}
private function getDefaultDbPort($driver)
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Symfony\Component\Console\Helper\QuestionHelper;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class DefaultConfigCustomizerPluginFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new $requestedName($container->get(QuestionHelper::class));
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class LanguageConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
const SUPPORTED_LANGUAGES = ['en', 'es'];
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'LANGUAGE');
if ($appConfig->hasLanguage() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported language? (Y/n):</question> '
))) {
return;
}
$appConfig->setLanguage([
'DEFAULT' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select default language for the application in general (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
'CLI' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select default language for CLI executions (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Shlinkio\Shlink\CLI\Install\Plugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class UrlShortenerConfigCustomizerPlugin extends AbstractConfigCustomizerPlugin
{
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param CustomizableAppConfig $appConfig
* @return void
* @throws RuntimeException
*/
public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig)
{
$this->printTitle($output, 'URL SHORTENER');
if ($appConfig->hasUrlShortener() && $this->questionHelper->ask($input, $output, new ConfirmationQuestion(
'<question>Do you want to keep imported URL shortener config? (Y/n):</question> '
))) {
return;
}
// Ask for URL shortener params
$appConfig->setUrlShortener([
'SCHEMA' => $this->questionHelper->ask($input, $output, new ChoiceQuestion(
'<question>Select schema for generated short URLs (defaults to http):</question>',
['http', 'https'],
0
)),
'HOSTNAME' => $this->ask($input, $output, 'Hostname for generated URLs'),
'CHARS' => $this->ask(
$input,
$output,
'Character set for generated short codes (leave empty to autogenerate one)',
null,
true
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS)
]);
}
}

View File

@ -0,0 +1,265 @@
<?php
namespace Shlinkio\Shlink\CLI\Model;
use Zend\Stdlib\ArraySerializableInterface;
final class CustomizableAppConfig implements ArraySerializableInterface
{
const SQLITE_DB_PATH = 'data/database.sqlite';
/**
* @var array
*/
private $database;
/**
* @var array
*/
private $urlShortener;
/**
* @var array
*/
private $language;
/**
* @var array
*/
private $app;
/**
* @var string
*/
private $importedInstallationPath;
/**
* @return array
*/
public function getDatabase()
{
return $this->database;
}
/**
* @param array $database
* @return $this
*/
public function setDatabase(array $database)
{
$this->database = $database;
return $this;
}
/**
* @return bool
*/
public function hasDatabase()
{
return ! empty($this->database);
}
/**
* @return array
*/
public function getUrlShortener()
{
return $this->urlShortener;
}
/**
* @param array $urlShortener
* @return $this
*/
public function setUrlShortener(array $urlShortener)
{
$this->urlShortener = $urlShortener;
return $this;
}
/**
* @return bool
*/
public function hasUrlShortener()
{
return ! empty($this->urlShortener);
}
/**
* @return array
*/
public function getLanguage()
{
return $this->language;
}
/**
* @param array $language
* @return $this
*/
public function setLanguage(array $language)
{
$this->language = $language;
return $this;
}
/**
* @return bool
*/
public function hasLanguage()
{
return ! empty($this->language);
}
/**
* @return array
*/
public function getApp()
{
return $this->app;
}
/**
* @param array $app
* @return $this
*/
public function setApp(array $app)
{
$this->app = $app;
return $this;
}
/**
* @return bool
*/
public function hasApp()
{
return ! empty($this->app);
}
/**
* @return string
*/
public function getImportedInstallationPath()
{
return $this->importedInstallationPath;
}
/**
* @param string $importedInstallationPath
* @return $this|self
*/
public function setImportedInstallationPath($importedInstallationPath)
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
/**
* @return bool
*/
public function hasImportedInstallationPath()
{
return $this->importedInstallationPath !== null;
}
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
{
if (isset($array['app_options'], $array['app_options']['secret_key'])) {
$this->setApp([
'SECRET' => $array['app_options']['secret_key'],
]);
}
if (isset($array['entity_manager'], $array['entity_manager']['connection'])) {
$this->deserializeDatabase($array['entity_manager']['connection']);
}
if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) {
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'],
'CLI' => $array['cli']['locale'],
]);
}
if (isset($array['url_shortener'])) {
$urlShortener = $array['url_shortener'];
$this->setUrlShortener([
'SCHEMA' => $urlShortener['domain']['schema'],
'HOSTNAME' => $urlShortener['domain']['hostname'],
'CHARS' => $urlShortener['shortcode_chars'],
]);
}
}
private function deserializeDatabase(array $conn)
{
if (! isset($conn['driver'])) {
return;
}
$driver = $conn['driver'];
$params = ['DRIVER' => $driver];
if ($driver !== 'pdo_sqlite') {
$params['USER'] = $conn['user'];
$params['PASSWORD'] = $conn['password'];
$params['NAME'] = $conn['dbname'];
$params['HOST'] = $conn['host'];
$params['PORT'] = $conn['port'];
}
$this->setDatabase($params);
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
{
$config = [
'app_options' => [
'secret_key' => $this->app['SECRET'],
],
'entity_manager' => [
'connection' => [
'driver' => $this->database['DRIVER'],
],
],
'translator' => [
'locale' => $this->language['DEFAULT'],
],
'cli' => [
'locale' => $this->language['CLI'],
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener['SCHEMA'],
'hostname' => $this->urlShortener['HOSTNAME'],
],
'shortcode_chars' => $this->urlShortener['CHARS'],
],
];
// Build dynamic database config based on selected driver
if ($this->database['DRIVER'] === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database['USER'];
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'];
$config['entity_manager']['connection']['host'] = $this->database['HOST'];
$config['entity_manager']['connection']['port'] = $this->database['PORT'];
if ($this->database['DRIVER'] === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
}

View File

@ -0,0 +1,2 @@
<?php
return [];

View File

@ -3,16 +3,25 @@ namespace ShlinkioTest\Shlink\CLI\Command\Install;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManagerInterface;
use Shlinkio\Shlink\CLI\Install\Plugin\ConfigCustomizerPluginInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface;
class InstallCommandTest extends TestCase
{
/**
* @var InstallCommand
*/
protected $command;
/**
* @var CommandTester
*/
@ -21,6 +30,10 @@ class InstallCommandTest extends TestCase
* @var ObjectProphecy
*/
protected $configWriter;
/**
* @var ObjectProphecy
*/
protected $filesystem;
public function setUp()
{
@ -31,81 +44,96 @@ class InstallCommandTest extends TestCase
$processHelper->setHelperSet(Argument::any())->willReturn(null);
$processHelper->run(Argument::cetera())->willReturn($processMock->reveal());
$this->filesystem = $this->prophesize(Filesystem::class);
$this->filesystem->exists(Argument::cetera())->willReturn(false);
$this->configWriter = $this->prophesize(WriterInterface::class);
$configCustomizer = $this->prophesize(ConfigCustomizerPluginInterface::class);
$configCustomizers = $this->prophesize(ConfigCustomizerPluginManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$app = new Application();
$helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal());
$app->setHelperSet($helperSet);
$this->configWriter = $this->prophesize(WriterInterface::class);
$command = new InstallCommand($this->configWriter->reveal());
$app->add($command);
$questionHelper = $command->getHelper('question');
$questionHelper->setInputStream($this->createInputStream());
$this->commandTester = new CommandTester($command);
}
protected function createInputStream()
{
$stream = fopen('php://memory', 'rb+', false);
fwrite($stream, <<<CLI_INPUT
shlink_db
alejandro
1234
0
doma.in
abc123BCA
1
my_secret
CLI_INPUT
$this->command = new InstallCommand(
$this->configWriter->reveal(),
$this->filesystem->reveal(),
$configCustomizers->reveal()
);
rewind($stream);
$app->add($this->command);
return $stream;
$this->commandTester = new CommandTester($this->command);
}
/**
* @test
*/
public function inputIsProperlyParsed()
public function generatedConfigIsProperlyPersisted()
{
$this->configWriter->toFile(Argument::any(), [
'app_options' => [
'secret_key' => 'my_secret',
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_mysql',
'dbname' => 'shlink_db',
'user' => 'alejandro',
'password' => '1234',
'host' => 'localhost',
'port' => '3306',
'driverOptions' => [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
]
],
],
'translator' => [
'locale' => 'en',
],
'cli' => [
'locale' => 'es',
],
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'doma.in',
],
'shortcode_chars' => 'abc123BCA',
],
], false)->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shlink:install',
$this->configWriter->toFile(Argument::any(), Argument::type('array'), false)->shouldBeCalledTimes(1);
$this->commandTester->execute([]);
}
/**
* @test
*/
public function cachedConfigIsDeletedIfExists()
{
/** @var MethodProphecy $appConfigExists */
$appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true);
/** @var MethodProphecy $appConfigRemove */
$appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willReturn(null);
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledTimes(1);
$appConfigRemove->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function exceptionWhileDeletingCachedConfigCancelsProcess()
{
/** @var MethodProphecy $appConfigExists */
$appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true);
/** @var MethodProphecy $appConfigRemove */
$appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willThrow(IOException::class);
/** @var MethodProphecy $configToFile */
$configToFile = $this->configWriter->toFile(Argument::cetera())->willReturn(true);
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledTimes(1);
$appConfigRemove->shouldHaveBeenCalledTimes(1);
$configToFile->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function whenCommandIsUpdatePreviousConfigCanBeImported()
{
$ref = new \ReflectionObject($this->command);
$prop = $ref->getProperty('isUpdate');
$prop->setAccessible(true);
$prop->setValue($this->command, true);
/** @var MethodProphecy $importedConfigExists */
$importedConfigExists = $this->filesystem->exists(
__DIR__ . '/../../../test-resources/' . InstallCommand::GENERATED_CONFIG_PATH
)->willReturn(true);
$this->commandTester->setInputs([
'',
'/foo/bar/wrong_previous_shlink',
'',
__DIR__ . '/../../../test-resources',
]);
$this->commandTester->execute([]);
$importedConfigExists->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class CreateTagCommandTest extends TestCase
{
/**
* @var CreateTagCommand
*/
private $command;
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new CreateTagCommand($this->tagService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function errorIsReturnedWhenNoTagsAreProvided()
{
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('You have to provide at least one tag name', $output);
}
/**
* @test
*/
public function serviceIsInvokedOnSuccess()
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $createTags */
$createTags = $this->tagService->createTags($tagNames)->willReturn([]);
$this->commandTester->execute([
'--name' => $tagNames,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Created tags: ["%s"]', implode('", "', $tagNames)), $output);
$createTags->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DeleteTagsCommandTest extends TestCase
{
/**
* @var DeleteTagsCommand
*/
private $command;
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new DeleteTagsCommand($this->tagService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function errorIsReturnedWhenNoTagsAreProvided()
{
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('You have to provide at least one tag name', $output);
}
/**
* @test
*/
public function serviceIsInvokedOnSuccess()
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $deleteTags */
$deleteTags = $this->tagService->deleteTags($tagNames)->will(function () {
});
$this->commandTester->execute([
'--name' => $tagNames,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Deleted tags: ["%s"]', implode('", "', $tagNames)), $output);
$deleteTags->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ListTagsCommandTest extends TestCase
{
/**
* @var ListTagsCommand
*/
private $command;
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new ListTagsCommand($this->tagService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function noTagsPrintsEmptyMessage()
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('No tags yet', $output);
$listTags->shouldHaveBeenCalled();
}
/**
* @test
*/
public function listOfTagsIsPrinted()
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([
(new Tag())->setName('foo'),
(new Tag())->setName('bar'),
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('foo', $output);
$this->assertContains('bar', $output);
$listTags->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class RenameTagCommandTest extends TestCase
{
/**
* @var RenameTagCommand
*/
private $command;
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$command = new RenameTagCommand($this->tagService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function errorIsPrintedIfExceptionIsThrown()
{
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class);
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('A tag with name "foo" was not found', $output);
$renameTag->shouldHaveBeenCalled();
}
/**
* @test
*/
public function successIsPrintedIfNoErrorOccurs()
{
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag());
$this->commandTester->execute([
'oldName' => $oldName,
'newName' => $newName,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tag properly renamed', $output);
$renameTag->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\ServiceManager;
class InstallApplicationFactoryTest extends TestCase
{
/**
* @var InstallApplicationFactory
*/
private $factory;
public function setUp()
{
$this->factory = new InstallApplicationFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
Filesystem::class => $this->prophesize(Filesystem::class)->reveal(),
]]), '');
$this->assertInstanceOf(Application::class, $instance);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class ApplicationConfigCustomizerPluginTest extends TestCase
{
/**
* @var ApplicationConfigCustomizerPlugin
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $questionHelper;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->plugin = new ApplicationConfigCustomizerPlugin($this->questionHelper->reveal());
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('the_secret');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'the_secret',
], $config->getApp());
$askSecret->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) {
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'the_new_secret';
});
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SECRET' => 'the_new_secret',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SECRET' => 'foo',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(1);
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerPluginTest extends TestCase
{
/**
* @var DatabaseConfigCustomizerPlugin
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $questionHelper;
/**
* @var ObjectProphecy
*/
private $filesystem;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->plugin = new DatabaseConfigCustomizerPlugin(
$this->questionHelper->reveal(),
$this->filesystem->reveal()
);
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('MySQL');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertTrue($config->hasDatabase());
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
], $config->getDatabase());
$askSecret->shouldHaveBeenCalledTimes(6);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) {
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'MySQL';
});
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(7);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function sqliteDatabaseIsImportedWhenRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
/** @var MethodProphecy $copy */
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_sqlite',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$ask->shouldHaveBeenCalledTimes(1);
$copy->shouldHaveBeenCalledTimes(1);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Install\Plugin\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory;
use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizerPlugin;
use Symfony\Component\Console\Helper\QuestionHelper;
use Zend\ServiceManager\ServiceManager;
class DefaultConfigCustomizerPluginFactoryTest extends TestCase
{
/**
* @var DefaultConfigCustomizerPluginFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new DefaultConfigCustomizerPluginFactory();
}
/**
* @test
*/
public function createsProperService()
{
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(),
]]), ApplicationConfigCustomizerPlugin::class);
$this->assertInstanceOf(ApplicationConfigCustomizerPlugin::class, $instance);
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
QuestionHelper::class => $this->prophesize(QuestionHelper::class)->reveal(),
]]), LanguageConfigCustomizerPlugin::class);
$this->assertInstanceOf(LanguageConfigCustomizerPlugin::class, $instance);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\LanguageConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class LanguageConfigCustomizerPluginTest extends TestCase
{
/**
* @var LanguageConfigCustomizerPlugin
*/
protected $plugin;
/**
* @var ObjectProphecy
*/
protected $questionHelper;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->plugin = new LanguageConfigCustomizerPlugin($this->questionHelper->reveal());
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertTrue($config->hasLanguage());
$this->assertEquals([
'DEFAULT' => 'en',
'CLI' => 'en',
], $config->getLanguage());
$askSecret->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) {
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'es';
});
$config = new CustomizableAppConfig();
$config->setLanguage([
'DEFAULT' => 'en',
'CLI' => 'en',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
'CLI' => 'es',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(3);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setLanguage([
'DEFAULT' => 'es',
'CLI' => 'es',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
'CLI' => 'es',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(1);
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Install\Plugin\UrlShortenerConfigCustomizerPlugin;
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class UrlShortenerConfigCustomizerPluginTest extends TestCase
{
/**
* @var UrlShortenerConfigCustomizerPlugin
*/
private $plugin;
/**
* @var ObjectProphecy
*/
private $questionHelper;
public function setUp()
{
$this->questionHelper = $this->prophesize(QuestionHelper::class);
$this->plugin = new UrlShortenerConfigCustomizerPlugin($this->questionHelper->reveal());
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
/** @var MethodProphecy $askSecret */
$askSecret = $this->questionHelper->ask(Argument::cetera())->willReturn('something');
$config = new CustomizableAppConfig();
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertTrue($config->hasUrlShortener());
$this->assertEquals([
'SCHEMA' => 'something',
'HOSTNAME' => 'something',
'CHARS' => 'something',
], $config->getUrlShortener());
$askSecret->shouldHaveBeenCalledTimes(3);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->will(function (array $args) {
$last = array_pop($args);
return $last instanceof ConfirmationQuestion ? false : 'foo';
});
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'bar',
'HOSTNAME' => 'bar',
'CHARS' => 'bar',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(4);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
/** @var MethodProphecy $ask */
$ask = $this->questionHelper->ask(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
]);
$this->plugin->process(new ArrayInput([]), new NullOutput(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(1);
}
}

View File

@ -1,36 +1,36 @@
<?php
if (! function_exists('env')) {
/**
* Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper
*
* @param string $key
* @param mixed $default
* @return mixed
* @link https://github.com/laravel/framework/blob/5.2/src/Illuminate/Foundation/helpers.php#L369
*/
function env($key, $default = null)
{
$value = getenv($key);
if ($value === false) {
return $default;
}
namespace Shlinkio\Shlink\Common;
switch (strtolower($value)) {
case 'true':
case '(true)':
return true;
case 'false':
case '(false)':
return false;
case 'empty':
case '(empty)':
return '';
case 'null':
case '(null)':
return null;
}
return trim($value);
/**
* Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper
*
* @param string $key
* @param mixed $default
* @return mixed
* @link https://github.com/laravel/framework/blob/5.2/src/Illuminate/Foundation/helpers.php#L369
*/
function env($key, $default = null)
{
$value = getenv($key);
if ($value === false) {
return $default;
}
switch (strtolower($value)) {
case 'true':
case '(true)':
return true;
case 'false':
case '(false)':
return false;
case 'empty':
case '(empty)':
return '';
case 'null':
case '(null)':
return null;
}
return trim($value);
}

View File

@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
@ -48,15 +49,14 @@ class CacheFactory implements FactoryInterface
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache'])
&& isset($config['cache']['adapter'])
if (isset($config['cache'], $config['cache']['adapter'])
&& in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)
) {
return $this->resolveCacheAdapter($config['cache']);
}
// If the adapter has not been set in config, create one based on environment
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
return Common\env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
/**
@ -80,7 +80,7 @@ class CacheFactory implements FactoryInterface
if (! isset($server['host'])) {
continue;
}
$port = isset($server['port']) ? intval($server['port']) : 11211;
$port = isset($server['port']) ? (int) $server['port'] : 11211;
$memcached->addServer($server['host'], $port);
}

View File

@ -0,0 +1,30 @@
<?php
namespace Shlinkio\Shlink\Common\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class EmptyResponseImplicitOptionsMiddlewareFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new ImplicitOptionsMiddleware(new EmptyResponse());
}
}

View File

@ -9,7 +9,7 @@ trait StringUtilsTrait
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
$randomString .= $characters[mt_rand(0, $charactersLength - 1)];
}
return $randomString;

View File

@ -0,0 +1,43 @@
<?php
namespace ShlinkioTest\Shlink\Common\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Expressive\Middleware\ImplicitOptionsMiddleware;
use Zend\ServiceManager\ServiceManager;
class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
{
/**
* @var EmptyResponseImplicitOptionsMiddlewareFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new EmptyResponseImplicitOptionsMiddlewareFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(ImplicitOptionsMiddleware::class, $instance);
}
/**
* @test
*/
public function responsePrototypeIsEmptyResponse()
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$ref = new \ReflectionObject($instance);
$prop = $ref->getProperty('response');
$prop->setAccessible(true);
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance));
}
}

View File

@ -16,6 +16,7 @@ return [
Service\VisitsTracker::class => AnnotatedFactory::class,
Service\ShortUrlService::class => AnnotatedFactory::class,
Service\VisitService::class => AnnotatedFactory::class,
Service\Tag\TagService::class => AnnotatedFactory::class,
// Middleware
Action\RedirectAction::class => AnnotatedFactory::class,

View File

@ -176,7 +176,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
return [
'shortCode' => $this->shortCode,
'originalUrl' => $this->originalUrl,
'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null,
'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ATOM) : null,
'visitsCount' => count($this->visits),
'tags' => $this->tags->toArray(),
];

View File

@ -3,13 +3,14 @@ namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Repository\TagRepository;
/**
* Class Tag
* @author
* @link
*
* @ORM\Entity()
* @ORM\Entity(repositoryClass=TagRepository::class)
* @ORM\Table(name="tags")
*/
class Tag extends AbstractEntity implements \JsonSerializable
@ -20,6 +21,11 @@ class Tag extends AbstractEntity implements \JsonSerializable
*/
protected $name;
public function __construct($name = null)
{
$this->name = $name;
}
/**
* @return string
*/

View File

@ -171,7 +171,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
{
return [
'referer' => $this->referer,
'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null,
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
'remoteAddr' => $this->remoteAddr,
'userAgent' => $this->userAgent,
'visitLocation' => $this->visitLocation,

View File

@ -0,0 +1,26 @@
<?php
namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\ExceptionInterface;
class EntityDoesNotExistException extends \RuntimeException implements ExceptionInterface
{
public static function createFromEntityAndConditions($entityName, array $conditions)
{
return new self(sprintf(
'Entity of type %s with params [%s] does not exist',
$entityName,
static::serializeParams($conditions)
));
}
private static function serializeParams(array $params)
{
$result = [];
foreach ($params as $key => $value) {
$result[] = sprintf('"%s" => "%s"', $key, $value);
}
return implode(', ', $result);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Shlinkio\Shlink\Core\Entity\Tag;
class TagRepository extends EntityRepository implements TagRepositoryInterface
{
/**
* Delete the tags identified by provided names
*
* @param array $names
* @return int The number of affected entries
*/
public function deleteByName(array $names)
{
if (empty($names)) {
return 0;
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->delete(Tag::class, 't')
->where($qb->expr()->in('t.name', $names));
return $qb->getQuery()->execute();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Common\Persistence\ObjectRepository;
interface TagRepositoryInterface extends ObjectRepository
{
/**
* Delete the tags identified by provided names
*
* @param array $names
* @return int The number of affected entries
*/
public function deleteByName(array $names);
}

View File

@ -0,0 +1,86 @@
<?php
namespace Shlinkio\Shlink\Core\Service\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
class TagService implements TagServiceInterface
{
use TagManagerTrait;
/**
* @var EntityManagerInterface
*/
private $em;
/**
* VisitService constructor.
* @param EntityManagerInterface $em
*
* @DI\Inject({"em"})
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @return Tag[]
* @throws \UnexpectedValueException
*/
public function listTags()
{
return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
}
/**
* @param array $tagNames
* @return void
*/
public function deleteTags(array $tagNames)
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
$repo->deleteByName($tagNames);
}
/**
* Provided a list of tag names, creates all that do not exist yet
*
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames)
{
$tags = $this->tagNamesToEntities($this->em, $tagNames);
$this->em->flush();
return $tags;
}
/**
* @param string $oldName
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
*/
public function renameTag($oldName, $newName)
{
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */
$tag = $this->em->getRepository(Tag::class)->findOneBy($criteria);
if ($tag === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria);
}
$tag->setName($newName);
$this->em->flush($tag);
return $tag;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
interface TagServiceInterface
{
/**
* @return Tag[]
*/
public function listTags();
/**
* @param string[] $tagNames
* @return void
*/
public function deleteTags(array $tagNames);
/**
* Provided a list of tag names, creates all that do not exist yet
*
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames);
/**
* @param string $oldName
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
*/
public function renameTag($oldName, $newName);
}

View File

@ -26,7 +26,7 @@ trait TagManagerTrait
}
/**
* Tag names are trimmed, lowercased and spaces are replaced by dashes
* Tag names are trimmed, lower cased and spaces are replaced by dashes
*
* @param string $tagName
* @return string

View File

@ -0,0 +1,134 @@
<?php
namespace ShlinkioTest\Shlink\Core\Service\Tag;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use PHPUnit\Framework\TestCase;
class TagServiceTest extends TestCase
{
/**
* @var TagService
*/
private $service;
/**
* @var ObjectProphecy
*/
private $em;
public function setUp()
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->service = new TagService($this->em->reveal());
}
/**
* @test
*/
public function listTagsDelegatesOnRepository()
{
$expected = [new Tag(), new Tag()];
$repo = $this->prophesize(EntityRepository::class);
/** @var MethodProphecy $find */
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$result = $this->service->listTags();
$this->assertEquals($expected, $result);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
}
/**
* @test
*/
public function deleteTagsDelegatesOnRepository()
{
$repo = $this->prophesize(TagRepository::class);
/** @var MethodProphecy $delete */
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->service->deleteTags(['foo', 'bar']);
$delete->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
}
/**
* @test
*/
public function createTagsPersistsEntities()
{
$repo = $this->prophesize(TagRepository::class);
/** @var MethodProphecy $find */
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag());
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
/** @var MethodProphecy $persist */
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
/** @var MethodProphecy $flush */
$flush = $this->em->flush()->willReturn(null);
$result = $this->service->createTags(['foo', 'bar']);
$this->assertCount(2, $result);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$persist->shouldHaveBeenCalledTimes(2);
$flush->shouldHaveBeenCalled();
}
/**
* @test
*/
public function renameInvalidTagThrowsException()
{
$repo = $this->prophesize(TagRepository::class);
/** @var MethodProphecy $find */
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find->shouldBeCalled();
$getRepo->shouldBeCalled();
$this->expectException(EntityDoesNotExistException::class);
$this->service->renameTag('foo', 'bar');
}
/**
* @test
*/
public function renameValidTagChangesItsName()
{
$expected = new Tag();
$repo = $this->prophesize(TagRepository::class);
/** @var MethodProphecy $find */
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected);
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
/** @var MethodProphecy $flush */
$flush = $this->em->flush($expected)->willReturn(null);
$tag = $this->service->renameTag('foo', 'bar');
$this->assertSame($expected, $tag);
$this->assertEquals('bar', $tag->getName());
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
}
}

View File

@ -18,7 +18,11 @@ return [
Action\ResolveUrlAction::class => AnnotatedFactory::class,
Action\GetVisitsAction::class => AnnotatedFactory::class,
Action\ListShortcodesAction::class => AnnotatedFactory::class,
Action\EditTagsAction::class => AnnotatedFactory::class,
Action\EditShortcodeTagsAction::class => AnnotatedFactory::class,
Action\Tag\ListTagsAction::class => AnnotatedFactory::class,
Action\Tag\DeleteTagsAction::class => AnnotatedFactory::class,
Action\Tag\CreateTagsAction::class => AnnotatedFactory::class,
Action\Tag\UpdateTagAction::class => AnnotatedFactory::class,
Middleware\BodyParserMiddleware::class => AnnotatedFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,

View File

@ -1,44 +1,75 @@
<?php
use Shlinkio\Shlink\Rest\Action;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
return [
'routes' => [
[
'name' => 'rest-authenticate',
'name' => Action\AuthenticateAction::class,
'path' => '/rest/v{version:1}/authenticate',
'middleware' => Action\AuthenticateAction::class,
'allowed_methods' => ['POST', 'OPTIONS'],
'allowed_methods' => [RequestMethod::METHOD_POST],
],
// Short codes
[
'name' => 'rest-create-shortcode',
'name' => Action\CreateShortcodeAction::class,
'path' => '/rest/v{version:1}/short-codes',
'middleware' => Action\CreateShortcodeAction::class,
'allowed_methods' => ['POST', 'OPTIONS'],
'allowed_methods' => [RequestMethod::METHOD_POST],
],
[
'name' => 'rest-resolve-url',
'name' => Action\ResolveUrlAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}',
'middleware' => Action\ResolveUrlAction::class,
'allowed_methods' => ['GET', 'OPTIONS'],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => 'rest-list-shortened-url',
'name' => Action\ListShortcodesAction::class,
'path' => '/rest/v{version:1}/short-codes',
'middleware' => Action\ListShortcodesAction::class,
'allowed_methods' => ['GET'],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => 'rest-get-visits',
'name' => Action\EditShortcodeTagsAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags',
'middleware' => Action\EditShortcodeTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
// Visits
[
'name' => Action\GetVisitsAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}/visits',
'middleware' => Action\GetVisitsAction::class,
'allowed_methods' => ['GET', 'OPTIONS'],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
// Tags
[
'name' => Action\Tag\ListTagsAction::class,
'path' => '/rest/v{version:1}/tags',
'middleware' => Action\Tag\ListTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => 'rest-edit-tags',
'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags',
'middleware' => Action\EditTagsAction::class,
'allowed_methods' => ['PUT', 'OPTIONS'],
'name' => Action\Tag\DeleteTagsAction::class,
'path' => '/rest/v{version:1}/tags',
'middleware' => Action\Tag\DeleteTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_DELETE],
],
[
'name' => Action\Tag\CreateTagsAction::class,
'path' => '/rest/v{version:1}/tags',
'middleware' => Action\Tag\CreateTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST],
],
[
'name' => Action\Tag\UpdateTagAction::class,
'path' => '/rest/v{version:1}/tags',
'middleware' => Action\Tag\UpdateTagAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
],

Binary file not shown.

View File

@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-08-21 18:17+0200\n"
"PO-Revision-Date: 2016-08-21 18:17+0200\n"
"POT-Creation-Date: 2017-07-16 09:39+0200\n"
"PO-Revision-Date: 2017-07-16 09:40+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.7.1\n"
"X-Generator: Poedit 2.0.1\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@ -50,6 +50,17 @@ msgstr "El código corto \"%s\" proporcionado no existe"
msgid "Provided short code \"%s\" has an invalid format"
msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido"
msgid ""
"You have to provide both 'oldName' and 'newName' params in order to properly "
"rename the tag"
msgstr ""
"Debes proporcionar tanto el parámetro 'oldName' como 'newName' para poder "
"renombrar la etiqueta correctamente"
#, php-format
msgid "It wasn't possible to find a tag with name '%s'"
msgstr "No fue posible encontrar una etiqueta con el nombre '%s'"
#, php-format
msgid "You need to provide the Bearer type in the %s header."
msgstr "Debes proporcionar el typo Bearer en la cabecera %s."

View File

@ -3,13 +3,9 @@ namespace Shlinkio\Shlink\Rest\Action;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Zend\Diactoros\Response\EmptyResponse;
abstract class AbstractRestAction implements MiddlewareInterface, RequestMethodInterface, StatusCodeInterface
{
@ -22,29 +18,4 @@ abstract class AbstractRestAction implements MiddlewareInterface, RequestMethodI
{
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
{
if ($request->getMethod() === self::METHOD_OPTIONS) {
return new EmptyResponse();
}
return $this->dispatch($request, $delegate);
}
/**
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
*/
abstract protected function dispatch(Request $request, DelegateInterface $delegate);
}

View File

@ -54,8 +54,9 @@ class AuthenticateAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
public function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
$authData = $request->getParsedBody();
if (! isset($authData['apiKey'])) {

View File

@ -55,8 +55,9 @@ class CreateShortcodeAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
public function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
$postData = $request->getParsedBody();
if (! isset($postData['longUrl'])) {

View File

@ -13,7 +13,7 @@ use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class EditTagsAction extends AbstractRestAction
class EditShortcodeTagsAction extends AbstractRestAction
{
/**
* @var ShortUrlServiceInterface
@ -25,7 +25,7 @@ class EditTagsAction extends AbstractRestAction
private $translator;
/**
* EditTagsAction constructor.
* EditShortcodeTagsAction constructor.
* @param ShortUrlServiceInterface $shortUrlService
* @param TranslatorInterface $translator
* @param LoggerInterface|null $logger
@ -46,8 +46,9 @@ class EditTagsAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
protected function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
$shortCode = $request->getAttribute('shortCode');
$bodyParams = $request->getParsedBody();

View File

@ -47,8 +47,9 @@ class GetVisitsAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
public function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
$shortCode = $request->getAttribute('shortCode');
$startDate = $this->getDateQueryParam($request, 'startDate');

View File

@ -6,7 +6,6 @@ use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@ -49,8 +48,9 @@ class ListShortcodesAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
public function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
try {
$params = $this->queryToListParams($request->getQueryParams());
@ -67,7 +67,7 @@ class ListShortcodesAction extends AbstractRestAction
/**
* @param array $query
* @return string
* @return array
*/
public function queryToListParams(array $query)
{

View File

@ -46,14 +46,15 @@ class ResolveUrlAction extends AbstractRestAction
* @param Request $request
* @param DelegateInterface $delegate
* @return null|Response
* @throws \InvalidArgumentException
*/
public function dispatch(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate)
{
$shortCode = $request->getAttribute('shortCode');
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($longUrl)) {
if ($longUrl === null) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),

View File

@ -0,0 +1,55 @@
<?php
namespace Shlinkio\Shlink\Rest\Action\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Zend\Diactoros\Response\JsonResponse;
class CreateTagsAction extends AbstractRestAction
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* CreateTagsAction constructor.
* @param TagServiceInterface $tagService
* @param LoggerInterface|null $logger
*
* @DI\Inject({TagService::class, LoggerInterface::class})
*/
public function __construct(TagServiceInterface $tagService, LoggerInterface $logger = null)
{
parent::__construct($logger);
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param DelegateInterface $delegate
*
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request, DelegateInterface $delegate)
{
$body = $request->getParsedBody();
$tags = isset($body['tags']) ? $body['tags'] : [];
return new JsonResponse([
'tags' => [
'data' => $this->tagService->createTags($tags)->toArray(),
],
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Shlinkio\Shlink\Rest\Action\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Zend\Diactoros\Response\EmptyResponse;
class DeleteTagsAction extends AbstractRestAction
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* DeleteTagsAction constructor.
* @param TagServiceInterface $tagService
* @param LoggerInterface|null $logger
*
* @DI\Inject({TagService::class, LoggerInterface::class})
*/
public function __construct(TagServiceInterface $tagService, LoggerInterface $logger = null)
{
parent::__construct($logger);
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param DelegateInterface $delegate
*
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, DelegateInterface $delegate)
{
$query = $request->getQueryParams();
$tags = isset($query['tags']) ? $query['tags'] : [];
$this->tagService->deleteTags($tags);
return new EmptyResponse();
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Shlinkio\Shlink\Rest\Action\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Zend\Diactoros\Response\JsonResponse;
class ListTagsAction extends AbstractRestAction
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* ListTagsAction constructor.
* @param TagServiceInterface $tagService
* @param LoggerInterface|null $logger
*
* @DI\Inject({TagService::class, LoggerInterface::class})
*/
public function __construct(TagServiceInterface $tagService, LoggerInterface $logger = null)
{
parent::__construct($logger);
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param DelegateInterface $delegate
*
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request, DelegateInterface $delegate)
{
return new JsonResponse([
'tags' => [
'data' => $this->tagService->listTags(),
],
]);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Shlinkio\Shlink\Rest\Action\Tag;
use Acelaya\ZsmAnnotatedServices\Annotation as DI;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\Translator;
use Zend\I18n\Translator\TranslatorInterface;
class UpdateTagAction extends AbstractRestAction
{
/**
* @var TagServiceInterface
*/
private $tagService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* UpdateTagAction constructor.
* @param TagServiceInterface $tagService
* @param TranslatorInterface $translator
* @param LoggerInterface|null $logger
*
* @DI\Inject({TagService::class, Translator::class, LoggerInterface::class})
*/
public function __construct(
TagServiceInterface $tagService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->tagService = $tagService;
$this->translator = $translator;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param DelegateInterface $delegate
*
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request, DelegateInterface $delegate)
{
$body = $request->getParsedBody();
if (! isset($body['oldName'], $body['newName'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => $this->translator->translate(
'You have to provide both \'oldName\' and \'newName\' params in order to properly rename the tag'
),
], self::STATUS_BAD_REQUEST);
}
try {
$this->tagService->renameTag($body['oldName'], $body['newName']);
return new EmptyResponse();
} catch (EntityDoesNotExistException $e) {
return new JsonResponse([
'error' => RestUtils::NOT_FOUND_ERROR,
'message' => sprintf(
$this->translator->translate('It wasn\'t possible to find a tag with name \'%s\''),
$body['oldName']
),
], self::STATUS_NOT_FOUND);
}
}
}

View File

@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Rest\Action\AuthenticateAction;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
@ -69,7 +70,7 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface, StatusCodeIn
$routeResult = $request->getAttribute(RouteResult::class);
if (! isset($routeResult)
|| $routeResult->isFailure()
|| $routeResult->getMatchedRouteName() === 'rest-authenticate'
|| $routeResult->getMatchedRouteName() === AuthenticateAction::class
|| $request->getMethod() === 'OPTIONS'
) {
return $delegate->process($request);

View File

@ -1,12 +1,13 @@
<?php
namespace Shlinkio\Shlink\Rest\Middleware;
use Fig\Http\Message\RequestMethodInterface;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class CrossDomainMiddleware implements MiddlewareInterface
class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface
{
/**
* Process an incoming server request and return a response, optionally delegating
@ -16,6 +17,7 @@ class CrossDomainMiddleware implements MiddlewareInterface
* @param DelegateInterface $delegate
*
* @return Response
* @throws \InvalidArgumentException
*/
public function process(Request $request, DelegateInterface $delegate)
{
@ -28,13 +30,14 @@ class CrossDomainMiddleware implements MiddlewareInterface
// Add Allow-Origin header
$response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin'))
->withHeader('Access-Control-Expose-Headers', 'Authorization');
if ($request->getMethod() !== 'OPTIONS') {
if ($request->getMethod() !== self::METHOD_OPTIONS) {
return $response;
}
// Add OPTIONS-specific headers
foreach ([
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be based on path
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be dynamic
// 'Access-Control-Allow-Methods' => $response->getHeaderLine('Allow'),
'Access-Control-Max-Age' => '1000',
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
] as $key => $value) {

View File

@ -6,15 +6,15 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Rest\Action\EditTagsAction;
use Shlinkio\Shlink\Rest\Action\EditShortcodeTagsAction;
use ShlinkioTest\Shlink\Common\Util\TestUtils;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class EditTagsActionTest extends TestCase
class EditShortcodeTagsActionTest extends TestCase
{
/**
* @var EditTagsAction
* @var EditShortcodeTagsAction
*/
protected $action;
/**
@ -25,7 +25,7 @@ class EditTagsActionTest extends TestCase
public function setUp()
{
$this->shortUrlService = $this->prophesize(ShortUrlService::class);
$this->action = new EditTagsAction($this->shortUrlService->reveal(), Translator::factory([]));
$this->action = new EditShortcodeTagsAction($this->shortUrlService->reveal(), Translator::factory([]));
}
/**

View File

@ -0,0 +1,56 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use Interop\Http\ServerMiddleware\DelegateInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction;
use Zend\Diactoros\ServerRequestFactory;
class CreateTagsActionTest extends TestCase
{
/**
* @var CreateTagsAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->action = new CreateTagsAction($this->tagService->reveal());
}
/**
* @test
* @dataProvider provideTags
* @param array|null $tags
*/
public function processDelegatesIntoService($tags)
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody(['tags' => $tags]);
/** @var MethodProphecy $deleteTags */
$deleteTags = $this->tagService->createTags($tags ?: [])->willReturn(new ArrayCollection());
$response = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal());
$this->assertEquals(200, $response->getStatusCode());
$deleteTags->shouldHaveBeenCalled();
}
public function provideTags()
{
return [
[['foo', 'bar', 'baz']],
[['some', 'thing']],
[null],
[[]],
];
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Interop\Http\ServerMiddleware\DelegateInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction;
use Zend\Diactoros\ServerRequestFactory;
class DeleteTagsActionTest extends TestCase
{
/**
* @var DeleteTagsAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->action = new DeleteTagsAction($this->tagService->reveal());
}
/**
* @test
* @dataProvider provideTags
* @param array|null $tags
*/
public function processDelegatesIntoService($tags)
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['tags' => $tags]);
/** @var MethodProphecy $deleteTags */
$deleteTags = $this->tagService->deleteTags($tags ?: []);
$response = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal());
$this->assertEquals(204, $response->getStatusCode());
$deleteTags->shouldHaveBeenCalled();
}
public function provideTags()
{
return [
[['foo', 'bar', 'baz']],
[['some', 'thing']],
[null],
[[]],
];
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Interop\Http\ServerMiddleware\DelegateInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Zend\Diactoros\ServerRequestFactory;
class ListTagsActionTest extends TestCase
{
/**
* @var ListTagsAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->action = new ListTagsAction($this->tagService->reveal());
}
/**
* @test
*/
public function returnsDataFromService()
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]);
$resp = $this->action->process(
ServerRequestFactory::fromGlobals(),
$this->prophesize(DelegateInterface::class)->reveal()
);
$this->assertEquals([
'tags' => [
'data' => ['foo', 'bar'],
],
], \json_decode((string) $resp->getBody(), true));
$listTags->shouldHaveBeenCalled();
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Interop\Http\ServerMiddleware\DelegateInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class UpdateTagActionTest extends TestCase
{
/**
* @var UpdateTagAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $tagService;
public function setUp()
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->action = new UpdateTagAction($this->tagService->reveal(), Translator::factory([]));
}
/**
* @test
* @dataProvider provideParams
* @param array $bodyParams
*/
public function whenInvalidParamsAreProvidedAnErrorIsReturned(array $bodyParams)
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody($bodyParams);
$resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal());
$this->assertEquals(400, $resp->getStatusCode());
}
public function provideParams()
{
return [
[['oldName' => 'foo']],
[['newName' => 'foo']],
[[]],
];
}
/**
* @test
*/
public function requestingInvalidTagReturnsError()
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'oldName' => 'foo',
'newName' => 'bar',
]);
/** @var MethodProphecy $rename */
$rename = $this->tagService->renameTag('foo', 'bar')->willThrow(EntityDoesNotExistException::class);
$resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal());
$this->assertEquals(404, $resp->getStatusCode());
$rename->shouldHaveBeenCalled();
}
/**
* @test
*/
public function correctInvocationRenamesTag()
{
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'oldName' => 'foo',
'newName' => 'bar',
]);
/** @var MethodProphecy $rename */
$rename = $this->tagService->renameTag('foo', 'bar')->willReturn(new Tag());
$resp = $this->action->process($request, $this->prophesize(DelegateInterface::class)->reveal());
$this->assertEquals(204, $resp->getStatusCode());
$rename->shouldHaveBeenCalled();
}
}

View File

@ -5,6 +5,7 @@ use Interop\Http\ServerMiddleware\DelegateInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Rest\Action\AuthenticateAction;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use ShlinkioTest\Shlink\Common\Util\TestUtils;
@ -56,7 +57,7 @@ class CheckAuthenticationMiddlewareTest extends TestCase
$request = ServerRequestFactory::fromGlobals()->withAttribute(
RouteResult::class,
RouteResult::fromRoute(new Route('foo', '', Route::HTTP_METHOD_ANY, 'rest-authenticate'), [])
RouteResult::fromRoute(new Route('foo', '', Route::HTTP_METHOD_ANY, AuthenticateAction::class))
);
$delegate = $this->prophesize(DelegateInterface::class);
/** @var MethodProphecy $process */