Merge pull request #199 from acelaya/feature/delete-short-codes

Delete short URLs
This commit is contained in:
Alejandro Celaya 2018-09-15 18:20:06 +02:00 committed by GitHub
commit ff8441fa95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1171 additions and 409 deletions

View file

@ -9,6 +9,7 @@ return [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
'disable_track_param' => null,
],
];

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
return [
'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
],
];

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180915110857 extends AbstractMigration
{
private const ON_DELETE_MAP = [
'visit_locations' => 'SET NULL',
'short_urls' => 'CASCADE',
];
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$foreignKeys = $visits->getForeignKeys();
// Remove all existing foreign keys and add them again with CASCADE delete
foreach ($foreignKeys as $foreignKey) {
$visits->removeForeignKey($foreignKey->getName());
$foreignTable = $foreignKey->getForeignTableName();
$visits->addForeignKeyConstraint(
$foreignTable,
$foreignKey->getLocalColumns(),
$foreignKey->getForeignColumns(),
[
'onDelete' => self::ON_DELETE_MAP[$foreignTable],
'onUpdate' => 'RESTRICT',
]
);
}
}
public function down(Schema $schema): void
{
// Nothing to run
}
}

View file

@ -159,5 +159,70 @@
}
}
}
},
"delete": {
"tags": [
"ShortCodes"
],
"summary": "Delete short code",
"description": "Deletes the short URL for provided short code.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
}
],
"security": [
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly deleted."
},
"400": {
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_SHORTCODE_DELETION",
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits."
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View file

@ -14,6 +14,7 @@ return [
Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class,
Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class,
Command\Shortcode\DeleteShortCodeCommand::NAME => Command\Shortcode\DeleteShortCodeCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,

View file

@ -22,12 +22,17 @@ return [
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\DeleteShortCodeCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@ -53,16 +58,24 @@ return [
PreviewGenerator::class,
'translator',
],
Command\Shortcode\DeleteShortCodeCommand::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
'translator',
],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class],

Binary file not shown.

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-08-04 16:35+0200\n"
"PO-Revision-Date: 2018-08-04 16:37+0200\n"
"POT-Creation-Date: 2018-09-15 17:57+0200\n"
"PO-Revision-Date: 2018-09-15 18:02+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@ -80,6 +80,41 @@ msgstr ""
msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid "Deletes a short URL"
msgstr "Elimina una URL"
msgid "The short code to be deleted"
msgstr "El código corto a eliminar"
msgid ""
"Ignores the safety visits threshold check, which could make short URLs with "
"many visits to be accidentally deleted"
msgstr ""
"Ignora el límite de seguridad de visitas, pudiendo resultar en el borrado "
"accidental de URLs con muchas visitas"
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
#, php-format
msgid ""
"It was not possible to delete the short URL with short code \"%s\" because "
"it has more than %s visits."
msgstr ""
"No se pudo eliminar la URL acortada con código corto \"%s\" porque tiene más "
"de %s visitas."
msgid "Do you want to delete it anyway?"
msgstr "¿Aún así quieres eliminarla?"
msgid "Short URL was not deleted."
msgstr "La URL corta no ha sido eliminada."
#, php-format
msgid "Short URL with short code \"%s\" successfully deleted."
msgstr "La URL acortada con el código corto \"%s\" eliminada correctamente."
msgid ""
"Processes and generates the previews for every URL, improving performance "
"for later web requests."
@ -183,12 +218,12 @@ msgstr "Origen"
msgid "Date"
msgstr "Fecha"
msgid "Remote Address"
msgstr "Dirección remota"
msgid "User agent"
msgstr "Agente de usuario"
msgid "Country"
msgstr "País"
msgid "List all short URLs"
msgstr "Listar todas las URLs cortas"
@ -218,8 +253,11 @@ msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code"
msgstr "Código corto"
msgid "Original URL"
msgstr "URL original"
msgid "Short URL"
msgstr "URL corta"
msgid "Long URL"
msgstr "URL larga"
msgid "Date created"
msgstr "Fecha de creación"
@ -253,10 +291,6 @@ msgstr "URL larga:"
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas."
@ -327,6 +361,12 @@ msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
#~ msgid "Remote Address"
#~ msgstr "Dirección remota"
#~ msgid "Original URL"
#~ msgstr "URL original"
#~ msgid "You have reached last page"
#~ msgstr "Has alcanzado la última página"

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteShortCodeCommand extends Command
{
public const NAME = 'short-code:delete';
private const ALIASES = [];
/**
* @var DeleteShortUrlServiceInterface
*/
private $deleteShortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, TranslatorInterface $translator)
{
$this->deleteShortUrlService = $deleteShortUrlService;
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Deletes a short URL')
)
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to be deleted')
)
->addOption(
'ignore-threshold',
'i',
InputOption::VALUE_NONE,
$this->translator->translate(
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted'
)
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$ignoreThreshold = $input->getOption('ignore-threshold');
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
} catch (Exception\InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
}
}
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
{
$warningMsg = \sprintf($this->translator->translate(
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.'
), $shortCode, $e->getVisitsThreshold());
$io->writeln('<bg=yellow>' . $warningMsg . '</>');
$forceDelete = $io->confirm($this->translator->translate('Do you want to delete it anyway?'), false);
if ($forceDelete) {
$this->runDelete($io, $shortCode, true);
} else {
$io->warning($this->translator->translate('Short URL was not deleted.'));
}
}
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
$io->success(\sprintf(
$this->translator->translate('Short URL with short code "%s" successfully deleted.'),
$shortCode
));
}
}

View file

@ -14,7 +14,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command
{
const NAME = 'shortcode:process-previews';
public const NAME = 'short-code:process-previews';
private const ALIASES = ['shortcode:process-previews'];
/**
* @var PreviewGeneratorInterface
@ -40,17 +41,19 @@ class GeneratePreviewCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$page = 1;
do {
@ -65,7 +68,7 @@ class GeneratePreviewCommand extends Command
(new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs'));
}
protected function processUrl($url, OutputInterface $output)
private function processUrl($url, OutputInterface $output): void
{
try {
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));

View file

@ -20,7 +20,8 @@ class GenerateShortcodeCommand extends Command
{
use ShortUrlBuilderTrait;
public const NAME = 'shortcode:generate';
public const NAME = 'short-code:generate';
private const ALIASES = ['shortcode:generate'];
/**
* @var UrlShortenerInterface
@ -46,36 +47,38 @@ class GenerateShortcodeCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
$this->translator->translate('Tags to apply to the new short URL')
)
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.'
))
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.'
))
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
'If provided, this slug will be used instead of generating a short code'
))
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
'This will limit the number of visits for this short URL.'
));
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
$this->translator->translate('Tags to apply to the new short URL')
)
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.'
))
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.'
))
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
'If provided, this slug will be used instead of generating a short code'
))
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
'This will limit the number of visits for this short URL.'
));
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
@ -91,7 +94,7 @@ class GenerateShortcodeCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
@ -140,7 +143,7 @@ class GenerateShortcodeCommand extends Command
}
}
private function getOptionalDate(InputInterface $input, string $fieldName)
private function getOptionalDate(InputInterface $input, string $fieldName): ?\DateTime
{
$since = $input->getOption($fieldName);
return $since !== null ? new \DateTime($since) : null;

View file

@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command
{
const NAME = 'shortcode:visits';
public const NAME = 'short-code:visits';
private const ALIASES = ['shortcode:visits'];
/**
* @var VisitsTrackerInterface
@ -33,9 +34,11 @@ class GetVisitsCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code')
)
@ -58,7 +61,7 @@ class GetVisitsCommand extends Command
);
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
@ -74,7 +77,7 @@ class GetVisitsCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
@ -101,7 +104,7 @@ class GetVisitsCommand extends Command
], $rows);
}
protected function getDateOption(InputInterface $input, $key)
private function getDateOption(InputInterface $input, $key)
{
$value = $input->getOption($key);
if (! empty($value)) {

View file

@ -18,7 +18,8 @@ class ListShortcodesCommand extends Command
{
use PaginatorUtilsTrait;
const NAME = 'shortcode:list';
public const NAME = 'short-code:list';
private const ALIASES = ['shortcode:list'];
/**
* @var ShortUrlServiceInterface
@ -46,49 +47,51 @@ class ListShortcodesCommand extends Command
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
}
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');

View file

@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command
{
const NAME = 'shortcode:parse';
public const NAME = 'short-code:parse';
private const ALIASES = ['shortcode:parse'];
/**
* @var UrlShortenerInterface
@ -33,18 +34,20 @@ class ResolveUrlCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
@ -60,7 +63,7 @@ class ResolveUrlCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\DeleteShortCodeCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DeleteShortCodeCommandTest extends TestCase
{
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $service;
public function setUp()
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$command = new DeleteShortCodeCommand($this->service->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfUrlIsProperlyDeleted()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function () {
});
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function invalidShortCodePrintsMessage()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
Exception\InvalidShortCodeException::class
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) {
$ignoreThreshold = \array_pop($args);
if (!$ignoreThreshold) {
throw new Exception\DeleteShortUrlException(10);
}
}
);
$this->commandTester->setInputs(['yes']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
new Exception\DeleteShortUrlException(10)
);
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
}

View file

@ -8,26 +8,19 @@ use Doctrine\ORM\Mapping as ORM;
abstract class AbstractEntity
{
/**
* @var int
* @var string
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @ORM\Column(name="id", type="bigint", options={"unsigned"=true})
*/
protected $id;
/**
* @return int
*/
public function getId()
public function getId(): string
{
return $this->id;
}
/**
* @param int $id
* @return $this
*/
public function setId($id)
public function setId(string $id): self
{
$this->id = $id;
return $this;

View file

@ -17,6 +17,7 @@ return [
'dependencies' => [
'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class,
Options\DeleteShortUrlsOptions::class => Options\DeleteShortUrlsOptionsFactory::class,
NotFoundHandler::class => ConfigAbstractFactory::class,
// Services
@ -25,6 +26,7 @@ return [
Service\ShortUrlService::class => ConfigAbstractFactory::class,
Service\VisitService::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
// Middleware
Action\RedirectAction::class => ConfigAbstractFactory::class,
@ -49,6 +51,7 @@ return [
Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
// Middleware
Action\RedirectAction::class => [

View file

@ -22,6 +22,10 @@ class QrCodeAction implements MiddlewareInterface
{
use ErrorResponseBuilderTrait;
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
/**
* @var RouterInterface
*/
@ -82,11 +86,11 @@ class QrCodeAction implements MiddlewareInterface
*/
private function getSizeParam(Request $request): int
{
$size = (int) $request->getAttribute('size', 300);
if ($size < 50) {
return 50;
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > 1000 ? 1000 : $size;
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
}

View file

@ -7,13 +7,14 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
/**
* Class ShortUrl
* @author
* @link
*
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\ShortUrlRepository")
* @ORM\Entity(repositoryClass=ShortUrlRepository::class)
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity
@ -22,7 +23,7 @@ class ShortUrl extends AbstractEntity
* @var string
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024)
*/
protected $originalUrl;
private $originalUrl;
/**
* @var string
* @ORM\Column(
@ -33,17 +34,17 @@ class ShortUrl extends AbstractEntity
* unique=true
* )
*/
protected $shortCode;
private $shortCode;
/**
* @var \DateTime
* @ORM\Column(name="date_created", type="datetime")
*/
protected $dateCreated;
private $dateCreated;
/**
* @var Collection|Visit[]
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/
protected $visits;
private $visits;
/**
* @var Collection|Tag[]
* @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"})
@ -53,46 +54,36 @@ class ShortUrl extends AbstractEntity
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* })
*/
protected $tags;
private $tags;
/**
* @var \DateTime
* @ORM\Column(name="valid_since", type="datetime", nullable=true)
*/
protected $validSince;
private $validSince;
/**
* @var \DateTime
* @ORM\Column(name="valid_until", type="datetime", nullable=true)
*/
protected $validUntil;
private $validUntil;
/**
* @var integer
* @ORM\Column(name="max_visits", type="integer", nullable=true)
*/
protected $maxVisits;
private $maxVisits;
/**
* ShortUrl constructor.
*/
public function __construct()
{
$this->shortCode = '';
$this->dateCreated = new \DateTime();
$this->visits = new ArrayCollection();
$this->shortCode = '';
$this->tags = new ArrayCollection();
}
/**
* @return string
*/
public function getLongUrl(): string
{
return $this->originalUrl;
}
/**
* @param string $longUrl
* @return $this
*/
public function setLongUrl(string $longUrl): self
{
$this->originalUrl = $longUrl;
@ -100,7 +91,6 @@ class ShortUrl extends AbstractEntity
}
/**
* @return string
* @deprecated Use getLongUrl() instead
*/
public function getOriginalUrl(): string
@ -109,8 +99,6 @@ class ShortUrl extends AbstractEntity
}
/**
* @param string $originalUrl
* @return $this
* @deprecated Use setLongUrl() instead
*/
public function setOriginalUrl(string $originalUrl): self
@ -118,37 +106,23 @@ class ShortUrl extends AbstractEntity
return $this->setLongUrl($originalUrl);
}
/**
* @return string
*/
public function getShortCode(): string
{
return $this->shortCode;
}
/**
* @param string $shortCode
* @return $this
*/
public function setShortCode(string $shortCode)
public function setShortCode(string $shortCode): self
{
$this->shortCode = $shortCode;
return $this;
}
/**
* @return \DateTime
*/
public function getDateCreated(): \DateTime
{
return $this->dateCreated;
}
/**
* @param \DateTime $dateCreated
* @return $this
*/
public function setDateCreated(\DateTime $dateCreated)
public function setDateCreated(\DateTime $dateCreated): self
{
$this->dateCreated = $dateCreated;
return $this;
@ -164,55 +138,36 @@ class ShortUrl extends AbstractEntity
/**
* @param Collection|Tag[] $tags
* @return $this
*/
public function setTags(Collection $tags)
public function setTags(Collection $tags): self
{
$this->tags = $tags;
return $this;
}
/**
* @param Tag $tag
* @return $this
*/
public function addTag(Tag $tag)
public function addTag(Tag $tag): self
{
$this->tags->add($tag);
return $this;
}
/**
* @return \DateTime|null
*/
public function getValidSince()
public function getValidSince(): ?\DateTime
{
return $this->validSince;
}
/**
* @param \DateTime|null $validSince
* @return $this|self
*/
public function setValidSince($validSince): self
public function setValidSince(?\DateTime $validSince): self
{
$this->validSince = $validSince;
return $this;
}
/**
* @return \DateTime|null
*/
public function getValidUntil()
public function getValidUntil(): ?\DateTime
{
return $this->validUntil;
}
/**
* @param \DateTime|null $validUntil
* @return $this|self
*/
public function setValidUntil($validUntil): self
public function setValidUntil(?\DateTime $validUntil): self
{
$this->validUntil = $validUntil;
return $this;
@ -220,11 +175,11 @@ class ShortUrl extends AbstractEntity
public function getVisitsCount(): int
{
return count($this->visits);
return \count($this->visits);
}
/**
* @param Collection $visits
* @param Collection|Visit[] $visits
* @return ShortUrl
* @internal
*/
@ -234,19 +189,12 @@ class ShortUrl extends AbstractEntity
return $this;
}
/**
* @return int|null
*/
public function getMaxVisits()
public function getMaxVisits(): ?int
{
return $this->maxVisits;
}
/**
* @param int|null $maxVisits
* @return $this|self
*/
public function setMaxVisits($maxVisits): self
public function setMaxVisits(?int $maxVisits): self
{
$this->maxVisits = $maxVisits;
return $this;

View file

@ -21,39 +21,25 @@ class Tag extends AbstractEntity implements \JsonSerializable
* @var string
* @ORM\Column(unique=true)
*/
protected $name;
private $name;
public function __construct($name = null)
{
$this->name = $name;
}
/**
* @return string
*/
public function getName()
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return $this
*/
public function setName($name)
public function setName(string $name)
{
$this->name = $name;
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): string
{
return $this->name;
}

View file

@ -7,13 +7,14 @@ use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
/**
* Class Visit
* @author
* @link
*
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository")
* @ORM\Entity(repositoryClass=VisitRepository::class)
* @ORM\Table(name="visits")
*/
class Visit extends AbstractEntity implements \JsonSerializable

View file

@ -21,159 +21,110 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryCode;
private $countryCode;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryName;
private $countryName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $regionName;
private $regionName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $cityName;
private $cityName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $latitude;
private $latitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $longitude;
private $longitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $timezone;
private $timezone;
/**
* @return string
*/
public function getCountryCode()
public function getCountryCode(): string
{
return $this->countryCode;
return $this->countryCode ?? '';
}
/**
* @param string $countryCode
* @return $this
*/
public function setCountryCode($countryCode)
public function setCountryCode(string $countryCode)
{
$this->countryCode = $countryCode;
return $this;
}
/**
* @return string
*/
public function getCountryName()
public function getCountryName(): string
{
return $this->countryName;
return $this->countryName ?? '';
}
/**
* @param string $countryName
* @return $this
*/
public function setCountryName($countryName)
public function setCountryName(string $countryName): self
{
$this->countryName = $countryName;
return $this;
}
/**
* @return string
*/
public function getRegionName()
public function getRegionName(): string
{
return $this->regionName;
return $this->regionName ?? '';
}
/**
* @param string $regionName
* @return $this
*/
public function setRegionName($regionName)
public function setRegionName(string $regionName): self
{
$this->regionName = $regionName;
return $this;
}
/**
* @return string
*/
public function getCityName()
public function getCityName(): string
{
return $this->cityName;
return $this->cityName ?? '';
}
/**
* @param string $cityName
* @return $this
*/
public function setCityName($cityName)
public function setCityName(string $cityName): self
{
$this->cityName = $cityName;
return $this;
}
/**
* @return string
*/
public function getLatitude()
public function getLatitude(): string
{
return $this->latitude;
return $this->latitude ?? '';
}
/**
* @param string $latitude
* @return $this
*/
public function setLatitude($latitude)
public function setLatitude(string $latitude): self
{
$this->latitude = $latitude;
return $this;
}
/**
* @return string
*/
public function getLongitude()
public function getLongitude(): string
{
return $this->longitude;
return $this->longitude ?? '';
}
/**
* @param string $longitude
* @return $this
*/
public function setLongitude($longitude)
public function setLongitude(string $longitude): self
{
$this->longitude = $longitude;
return $this;
}
/**
* @return string
*/
public function getTimezone()
public function getTimezone(): string
{
return $this->timezone;
return $this->timezone ?? '';
}
/**
* @param string $timezone
* @return $this
*/
public function setTimezone($timezone)
public function setTimezone(string $timezone): self
{
$this->timezone = $timezone;
return $this;
@ -181,41 +132,36 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
public function exchangeArray(array $array): void
{
if (array_key_exists('country_code', $array)) {
if (\array_key_exists('country_code', $array)) {
$this->setCountryCode($array['country_code']);
}
if (array_key_exists('country_name', $array)) {
if (\array_key_exists('country_name', $array)) {
$this->setCountryName($array['country_name']);
}
if (array_key_exists('region_name', $array)) {
if (\array_key_exists('region_name', $array)) {
$this->setRegionName($array['region_name']);
}
if (array_key_exists('city', $array)) {
if (\array_key_exists('city', $array)) {
$this->setCityName($array['city']);
}
if (array_key_exists('latitude', $array)) {
if (\array_key_exists('latitude', $array)) {
$this->setLatitude($array['latitude']);
}
if (array_key_exists('longitude', $array)) {
if (\array_key_exists('longitude', $array)) {
$this->setLongitude($array['longitude']);
}
if (array_key_exists('time_zone', $array)) {
if (\array_key_exists('time_zone', $array)) {
$this->setTimezone($array['time_zone']);
}
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
public function getArrayCopy(): array
{
return [
'countryCode' => $this->countryCode,
@ -228,14 +174,7 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
];
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): array
{
return $this->getArrayCopy();
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Throwable;
class DeleteShortUrlException extends RuntimeException
{
/**
* @var int
*/
private $visitsThreshold;
public function __construct(int $visitsThreshold, string $message = '', int $code = 0, Throwable $previous = null)
{
$this->visitsThreshold = $visitsThreshold;
parent::__construct($message, $code, $previous);
}
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
{
return new self($threshold, \sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode,
$threshold
));
}
public function getVisitsThreshold(): int
{
return $this->visitsThreshold;
}
}

View file

@ -7,9 +7,9 @@ class InvalidShortCodeException extends RuntimeException
{
public static function fromCharset($shortCode, $charSet, \Exception $previous = null)
{
$code = isset($previous) ? $previous->getCode() : -1;
$code = $previous !== null ? $previous->getCode() : -1;
return new static(
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
\sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
$code,
$previous
);
@ -17,6 +17,6 @@ class InvalidShortCodeException extends RuntimeException
public static function fromNotFoundShortCode($shortCode)
{
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
return new static(\sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
}
}

View file

@ -26,6 +26,6 @@ class AppOptionsFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
return new AppOptions(isset($config['app_options']) ? $config['app_options'] : []);
return new AppOptions($config['app_options'] ?? []);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Zend\Stdlib\AbstractOptions;
class DeleteShortUrlsOptions extends AbstractOptions
{
private $visitsThreshold = 15;
private $checkVisitsThreshold = true;
public function getVisitsThreshold(): int
{
return $this->visitsThreshold;
}
protected function setVisitsThreshold(int $visitsThreshold): self
{
$this->visitsThreshold = $visitsThreshold;
return $this;
}
public function doCheckVisitsThreshold(): bool
{
return $this->checkVisitsThreshold;
}
protected function setCheckVisitsThreshold(bool $checkVisitsThreshold): self
{
$this->checkVisitsThreshold = $checkVisitsThreshold;
return $this;
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class DeleteShortUrlsOptionsFactory 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)
{
$config = $container->has('config') ? $container->get('config') : [];
return new DeleteShortUrlsOptions($config['delete_short_urls'] ?? []);
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
use FindShortCodeTrait;
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var DeleteShortUrlsOptions
*/
private $deleteShortUrlsOptions;
public function __construct(EntityManagerInterface $em, DeleteShortUrlsOptions $deleteShortUrlsOptions)
{
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
}
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void
{
$shortUrl = $this->findByShortCode($this->em, $shortCode);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->deleteShortUrlsOptions->getVisitsThreshold(),
$shortUrl->getShortCode()
);
}
$this->em->remove($shortUrl);
$this->em->flush();
}
private function isThresholdReached(ShortUrl $shortUrl): bool
{
if (! $this->deleteShortUrlsOptions->doCheckVisitsThreshold()) {
return false;
}
return $shortUrl->getVisitsCount() >= $this->deleteShortUrlsOptions->getVisitsThreshold();
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
interface DeleteShortUrlServiceInterface
{
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
trait FindShortCodeTrait
{
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
*/
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;
}
}

View file

@ -9,11 +9,13 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\FindShortCodeTrait;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Zend\Paginator\Paginator;
class ShortUrlService implements ShortUrlServiceInterface
{
use FindShortCodeTrait;
use TagManagerTrait;
/**
@ -27,13 +29,11 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param int $page
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @param string[] $tags
* @param array|string|null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null)
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null)
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
@ -45,14 +45,12 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param string $shortCode
* @param string[] $tags
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
{
$shortUrl = $this->findByShortCode($shortCode);
$shortUrl = $this->findByShortCode($this->em, $shortCode);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
@ -60,14 +58,11 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl
{
$shortUrl = $this->findByShortCode($shortCode);
$shortUrl = $this->findByShortCode($this->em, $shortCode);
if ($shortCodeMeta->hasValidSince()) {
$shortUrl->setValidSince($shortCodeMeta->getValidSince());
}
@ -81,23 +76,6 @@ class ShortUrlService implements ShortUrlServiceInterface
/** @var ORM\EntityManager $em */
$em = $this->em;
$em->flush($shortUrl);
return $shortUrl;
}
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
*/
private function findByShortCode(string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;
}

View file

@ -11,26 +11,19 @@ use Zend\Paginator\Paginator;
interface ShortUrlServiceInterface
{
/**
* @param int $page
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @param string[] $tags
* @param array|string|null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null);
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null);
/**
* @param string $shortCode
* @param string[] $tags
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl;

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
class DeleteShortUrlExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideMessages
*/
public function fromVisitsThresholdGeneratesMessageProperly(
int $threshold,
string $shortCode,
string $expectedMessage
) {
$e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode);
$this->assertEquals($expectedMessage, $e->getMessage());
}
public function provideMessages(): array
{
return [
[
50,
'abc123',
'Impossible to delete short URL with short code "abc123" since it has more than "50" visits.',
],
[
33,
'def456',
'Impossible to delete short URL with short code "def456" since it has more than "33" visits.',
],
[
5713,
'foobar',
'Impossible to delete short URL with short code "foobar" since it has more than "5713" visits.',
],
];
}
/**
* @test
* @dataProvider provideThresholds
*/
public function visitsThresholdIsProperlyReturned(int $threshold)
{
$e = new DeleteShortUrlException($threshold);
$this->assertEquals($threshold, $e->getVisitsThreshold());
}
public function provideThresholds(): array
{
return \array_map(function (int $number) {
return [$number];
}, \range(5, 50, 5));
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\ShortUrl;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlService;
class DeleteShortUrlServiceTest extends TestCase
{
/**
* @var DeleteShortUrlService
*/
private $service;
/**
* @var ObjectProphecy
*/
private $em;
public function setUp()
{
$shortUrl = (new ShortUrl())->setShortCode('abc123')
->setVisits(new ArrayCollection(\array_map(function () {
return new Visit();
}, \range(0, 10))));
$this->em = $this->prophesize(EntityManagerInterface::class);
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneBy(Argument::type('array'))->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
}
/**
* @test
*/
public function deleteByShortCodeThrowsExceptionWhenThresholdIsReached()
{
$service = $this->createService();
$this->expectException(DeleteShortUrlException::class);
$this->expectExceptionMessage(
'Impossible to delete short URL with short code "abc123" since it has more than "5" visits.'
);
$service->deleteByShortCode('abc123');
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButExplicitlyIgnored()
{
$service = $this->createService();
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123', true);
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButCheckIsDisabled()
{
$service = $this->createService(false);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsNotReached()
{
$service = $this->createService(true, 100);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService
{
return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions([
'visitsThreshold' => $visitsThreshold,
'checkVisitsThreshold' => $checkVisitsThreshold,
]));
}
}

View file

@ -54,7 +54,7 @@ class UrlShortenerTest extends TestCase
$this->em->persist(Argument::any())->will(function ($arguments) {
/** @var ShortUrl $shortUrl */
$shortUrl = $arguments[0];
$shortUrl->setId(10);
$shortUrl->setId('10');
});
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn(null);

View file

@ -23,6 +23,7 @@ return [
Action\ShortCode\CreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\SingleStepCreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\EditShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\DeleteShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ResolveUrlAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ListShortCodesAction::class => ConfigAbstractFactory::class,
@ -58,7 +59,12 @@ return [
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortCode\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',],
Action\ShortCode\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\DeleteShortCodeAction::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
'Logger_Shlink',
],
Action\ShortCode\ResolveUrlAction::class => [
Service\UrlShortener::class,
'translator',

View file

@ -18,6 +18,7 @@ return [
Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class,
]),
Action\ShortCode\EditShortCodeAction::getRouteDef(),
Action\ShortCode\DeleteShortCodeAction::getRouteDef(),
Action\ShortCode\ResolveUrlAction::getRouteDef(),
Action\ShortCode\ListShortCodesAction::getRouteDef(),
Action\ShortCode\EditShortCodeTagsAction::getRouteDef(),

Binary file not shown.

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-05-06 12:34+0200\n"
"PO-Revision-Date: 2018-05-06 12:35+0200\n"
"POT-Creation-Date: 2018-09-15 18:02+0200\n"
"PO-Revision-Date: 2018-09-15 18:03+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@ -43,6 +43,14 @@ msgstr "No se ha proporcionado una URL"
msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
#, php-format
msgid ""
"It is not possible to delete URL with short code \"%s\" because it has "
"reached more than \"%s\" visits."
msgstr ""
"No es posible eliminar la URL con el código corto \"%s\" porque ha alcanzado "
"más de \"%s\" visitas."
msgid "Provided data is invalid."
msgstr "Los datos proporcionados son inválidos."

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
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\TranslatorInterface;
class DeleteShortCodeAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE];
/**
* @var DeleteShortUrlServiceInterface
*/
private $deleteShortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(
DeleteShortUrlServiceInterface $deleteShortUrlService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->deleteShortUrlService = $deleteShortUrlService;
$this->translator = $translator;
}
/**
* Handle the request and return a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
try {
$this->deleteShortUrlService->deleteByShortCode($shortCode);
return new EmptyResponse();
} catch (Exception\InvalidShortCodeException $e) {
$this->logger->warning(
\sprintf('Provided short code %s does not belong to any URL.', $shortCode) . PHP_EOL . $e
);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => \sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception\DeleteShortUrlException $e) {
$this->logger->warning('Provided data is invalid.' . PHP_EOL . $e);
$messagePlaceholder = $this->translator->translate(
'It is not possible to delete URL with short code "%s" because it has reached more than "%s" visits.'
);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => \sprintf($messagePlaceholder, $shortCode, $e->getVisitsThreshold()),
], self::STATUS_BAD_REQUEST);
}
}
}

View file

@ -75,7 +75,7 @@ class ListShortCodesAction extends AbstractRestAction
private function queryToListParams(array $query): array
{
return [
$query['page'] ?? 1,
(int) ($query['page'] ?? 1),
$query['searchTerm'] ?? null,
$query['tags'] ?? [],
$query['orderBy'] ?? null,

View file

@ -23,17 +23,17 @@ class ApiKey extends AbstractEntity
* @var string
* @ORM\Column(name="`key`", nullable=false, unique=true)
*/
protected $key;
private $key;
/**
* @var \DateTime|null
* @ORM\Column(name="expiration_date", nullable=true, type="datetime")
*/
protected $expirationDate;
private $expirationDate;
/**
* @var bool
* @ORM\Column(type="boolean")
*/
protected $enabled;
private $enabled;
public function __construct()
{
@ -41,45 +41,28 @@ class ApiKey extends AbstractEntity
$this->key = $this->generateV4Uuid();
}
/**
* @return string
*/
public function getKey(): string
{
return $this->key;
}
/**
* @param string $key
* @return $this
*/
public function setKey($key): self
public function setKey(string $key): self
{
$this->key = $key;
return $this;
}
/**
* @return \DateTime|null
*/
public function getExpirationDate(): ?\DateTime
{
return $this->expirationDate;
}
/**
* @param \DateTime $expirationDate
* @return $this
*/
public function setExpirationDate($expirationDate): self
public function setExpirationDate(\DateTime $expirationDate): self
{
$this->expirationDate = $expirationDate;
return $this;
}
/**
* @return bool
*/
public function isExpired(): bool
{
if ($this->expirationDate === null) {
@ -89,29 +72,17 @@ class ApiKey extends AbstractEntity
return $this->expirationDate < new \DateTime();
}
/**
* @return boolean
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* @param boolean $enabled
* @return $this
*/
public function setEnabled($enabled): self
public function setEnabled(bool $enabled): self
{
$this->enabled = $enabled;
return $this;
}
/**
* Disables this API key
*
* @return $this
*/
public function disable(): self
{
return $this->setEnabled(false);
@ -119,19 +90,12 @@ class ApiKey extends AbstractEntity
/**
* Tells if this api key is enabled and not expired
*
* @return bool
*/
public function isValid(): bool
{
return $this->isEnabled() && ! $this->isExpired();
}
/**
* The string representation of an API key is the key itself
*
* @return string
*/
public function __toString(): string
{
return $this->getKey();

View file

@ -10,6 +10,7 @@ use Shlinkio\Shlink\Rest\Exception as Rest;
class RestUtils
{
public const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE';
public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION';
public const INVALID_URL_ERROR = 'INVALID_URL';
public const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT';
public const INVALID_SLUG_ERROR = 'INVALID_SLUG';
@ -35,6 +36,8 @@ class RestUtils
return self::INVALID_ARGUMENT_ERROR;
case $e instanceof Rest\AuthenticationException:
return self::INVALID_CREDENTIALS_ERROR;
case $e instanceof Core\DeleteShortUrlException:
return self::INVALID_SHORTCODE_DELETION_ERROR;
default:
return self::UNKNOWN_ERROR;
}

View file

@ -55,7 +55,7 @@ class AuthenticateActionTest extends TestCase
*/
public function properApiKeyReturnsTokenInResponse()
{
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId(5))
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId('5'))
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withParsedBody([

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\ShortCode\DeleteShortCodeAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class DeleteShortCodeActionTest extends TestCase
{
/**
* @var DeleteShortCodeAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $service;
public function setUp()
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$this->action = new DeleteShortCodeAction($this->service->reveal(), Translator::factory([]));
}
/**
* @test
*/
public function emptyResponseIsReturnedIfProperlyDeleted()
{
$deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->will(function () {
});
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$this->assertEquals(204, $resp->getStatusCode());
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
* @dataProvider provideExceptions
*/
public function returnsErrorResponseInCaseOfException(\Throwable $e, string $error, int $statusCode)
{
$deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->willThrow($e);
/** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$payload = $resp->getPayload();
$this->assertEquals($statusCode, $resp->getStatusCode());
$this->assertEquals($error, $payload['error']);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
public function provideExceptions(): array
{
return [
[new Exception\InvalidShortCodeException(), RestUtils::INVALID_SHORTCODE_ERROR, 404],
[new Exception\DeleteShortUrlException(5), RestUtils::INVALID_SHORTCODE_DELETION_ERROR, 400],
];
}
}

View file

@ -30,7 +30,7 @@ class JWTServiceTest extends TestCase
*/
public function tokenIsProperlyCreated()
{
$id = 34;
$id = '34';
$token = $this->service->create((new ApiKey())->setId($id));
$payload = (array) JWT::decode($token, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThanOrEqual($payload['iat'], time());