diff --git a/data/migrations/Version20160820191203.php b/data/migrations/Version20160820191203.php new file mode 100644 index 00000000..29e81040 --- /dev/null +++ b/data/migrations/Version20160820191203.php @@ -0,0 +1,80 @@ +getTables(); + foreach ($tables as $table) { + if ($table->getName() === 'tags') { + return; + } + } + + $this->createTagsTable($schema); + $this->createShortUrlsInTagsTable($schema); + } + + protected function createTagsTable(Schema $schema) + { + $table = $schema->createTable('tags'); + $table->addColumn('id', Type::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('name', Type::STRING, [ + 'length' => 255, + 'notnull' => true, + ]); + $table->addUniqueIndex(['name']); + + $table->setPrimaryKey(['id']); + } + + protected function createShortUrlsInTagsTable(Schema $schema) + { + $table = $schema->createTable('short_urls_in_tags'); + $table->addColumn('short_url_id', Type::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $table->addColumn('tag_id', Type::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('tags', ['tag_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->setPrimaryKey(['short_url_id', 'tag_id']); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + $schema->dropTable('short_urls_in_tags'); + $schema->dropTable('tags'); + } +} diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index f0702c6d..87678715 100644 Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po index d5e49206..b2ac0670 100644 --- a/module/CLI/lang/es.po +++ b/module/CLI/lang/es.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-08-18 17:24+0200\n" -"PO-Revision-Date: 2016-08-18 17:26+0200\n" +"POT-Creation-Date: 2016-08-21 18:16+0200\n" +"PO-Revision-Date: 2016-08-21 18:16+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -104,6 +104,9 @@ msgstr "" msgid "The long URL to parse" msgstr "La URL larga a procesar" +msgid "Tags to apply to the new short URL" +msgstr "Etiquetas a aplicar a la nueva URL acortada" + msgid "A long URL was not provided. Which URL do you want to shorten?:" msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" @@ -159,6 +162,9 @@ msgstr "Listar todas las URLs cortas" msgid "The first page to list (%s items per page)" msgstr "La primera página a listar (%s elementos por página)" +msgid "Whether to display the tags or not" +msgstr "Si se desea mostrar las etiquetas o no" + msgid "Short code" msgstr "Código corto" @@ -171,6 +177,9 @@ msgstr "Fecha de creación" msgid "Visits count" msgstr "Número de visitas" +msgid "Tags" +msgstr "Etiquetas" + msgid "You have reached last page" msgstr "Has alcanzado la última página" diff --git a/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php index 2830fb40..eaa2d634 100644 --- a/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php +++ b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Zend\Diactoros\Uri; @@ -54,7 +55,13 @@ class GenerateShortcodeCommand extends Command ->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')); + ->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse')) + ->addOption( + 'tags', + 't', + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, + $this->translator->translate('Tags to apply to the new short URL') + ); } public function interact(InputInterface $input, OutputInterface $output) @@ -80,6 +87,13 @@ class GenerateShortcodeCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { $longUrl = $input->getArgument('longUrl'); + $tags = $input->getOption('tags'); + $processedTags = []; + foreach ($tags as $key => $tag) { + $explodedTags = explode(',', $tag); + $processedTags = array_merge($processedTags, $explodedTags); + } + $tags = $processedTags; try { if (! isset($longUrl)) { @@ -87,10 +101,10 @@ class GenerateShortcodeCommand extends Command return; } - $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags); $shortUrl = (new Uri())->withPath($shortCode) - ->withScheme($this->domainConfig['schema']) - ->withHost($this->domainConfig['hostname']); + ->withScheme($this->domainConfig['schema']) + ->withHost($this->domainConfig['hostname']); $output->writeln([ sprintf('%s %s', $this->translator->translate('Processed URL:'), $longUrl), diff --git a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php index d57d3311..a3f77903 100644 --- a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php +++ b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php @@ -55,12 +55,20 @@ class ListShortcodesCommand extends Command PaginableRepositoryAdapter::ITEMS_PER_PAGE ), 1 + ) + ->addOption( + 'tags', + 't', + InputOption::VALUE_NONE, + $this->translator->translate('Whether to display the tags or not') ); } public function execute(InputInterface $input, OutputInterface $output) { $page = intval($input->getOption('page')); + $showTags = $input->getOption('tags'); + /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); @@ -68,15 +76,31 @@ class ListShortcodesCommand extends Command $result = $this->shortUrlService->listShortUrls($page); $page++; $table = new Table($output); - $table->setHeaders([ + + $headers = [ $this->translator->translate('Short code'), $this->translator->translate('Original URL'), $this->translator->translate('Date created'), $this->translator->translate('Visits count'), - ]); + ]; + if ($showTags) { + $headers[] = $this->translator->translate('Tags'); + } + $table->setHeaders($headers); foreach ($result as $row) { - $table->addRow(array_values($row->jsonSerialize())); + $shortUrl = $row->jsonSerialize(); + if ($showTags) { + $shortUrl['tags'] = []; + foreach ($row->getTags() as $tag) { + $shortUrl['tags'][] = $tag->getName(); + } + $shortUrl['tags'] = implode(', ', $shortUrl['tags']); + } else { + unset($shortUrl['tags']); + } + + $table->addRow(array_values($shortUrl)); } $table->render(); diff --git a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php index 43367f31..e2bf9c6e 100644 --- a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php @@ -39,8 +39,8 @@ class GenerateShortcodeCommandTest extends TestCase */ public function properShortCodeIsCreatedIfLongUrlIsCorrect() { - $this->urlShortener->urlToShortCode(Argument::any())->willReturn('abc123') - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123') + ->shouldBeCalledTimes(1); $this->commandTester->execute([ 'command' => 'shortcode:generate', @@ -55,8 +55,8 @@ class GenerateShortcodeCommandTest extends TestCase */ public function exceptionWhileParsingLongUrlOutputsError() { - $this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException()) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException()) + ->shouldBeCalledTimes(1); $this->commandTester->execute([ 'command' => 'shortcode:generate', diff --git a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php index d8e4a685..a426eb06 100644 --- a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php +++ b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php @@ -108,6 +108,23 @@ class ListShortcodesCommandTest extends TestCase ]); } + /** + * @test + */ + public function ifTagsFlagIsProvidedTagsColumnIsIncluded() + { + $this->questionHelper->setInputStream($this->getInputStream('\n')); + $this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:list', + '--tags' => true, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'Tags') > 0); + } + protected function getInputStream($inputData) { $stream = fopen('php://memory', 'r+', false); diff --git a/module/Core/lang/es.mo b/module/Core/lang/es.mo index d34bb83b..efc80eca 100644 Binary files a/module/Core/lang/es.mo and b/module/Core/lang/es.mo differ diff --git a/module/Core/lang/es.po b/module/Core/lang/es.po index 3393f072..3a3d4809 100644 --- a/module/Core/lang/es.po +++ b/module/Core/lang/es.po @@ -1,9 +1,9 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-07-21 16:50+0200\n" -"PO-Revision-Date: 2016-07-21 16:51+0200\n" -"Last-Translator: \n" +"POT-Creation-Date: 2016-08-21 18:17+0200\n" +"PO-Revision-Date: 2016-08-21 18:17+0200\n" +"Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" "MIME-Version: 1.0\n" diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 1100a363..cf1ed87e 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -42,6 +42,16 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY") */ protected $visits; + /** + * @var Collection|Tag[] + * @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"}) + * @ORM\JoinTable(name="short_urls_in_tags", joinColumns={ + * @ORM\JoinColumn(name="short_url_id", referencedColumnName="id") + * }, inverseJoinColumns={ + * @ORM\JoinColumn(name="tag_id", referencedColumnName="id") + * }) + */ + protected $tags; /** * ShortUrl constructor. @@ -51,6 +61,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable $this->setDateCreated(new \DateTime()); $this->setVisits(new ArrayCollection()); $this->setShortCode(''); + $this->tags = new ArrayCollection(); } /** @@ -125,6 +136,34 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable return $this; } + /** + * @return Collection|Tag[] + */ + public function getTags() + { + return $this->tags; + } + + /** + * @param Collection|Tag[] $tags + * @return $this + */ + public function setTags($tags) + { + $this->tags = $tags; + return $this; + } + + /** + * @param Tag $tag + * @return $this + */ + public function addTag(Tag $tag) + { + $this->tags->add($tag); + return $this; + } + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php @@ -139,6 +178,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable 'originalUrl' => $this->originalUrl, 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null, 'visitsCount' => count($this->visits), + 'tags' => $this->tags->toArray(), ]; } } diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php new file mode 100644 index 00000000..47123ff9 --- /dev/null +++ b/module/Core/src/Entity/Tag.php @@ -0,0 +1,52 @@ +name; + } + + /** + * @param string $name + * @return $this + */ + public function setName($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 json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return $this->name; + } +} diff --git a/module/Core/src/Exception/InvalidShortCodeException.php b/module/Core/src/Exception/InvalidShortCodeException.php index b1e23d0b..85cc3d54 100644 --- a/module/Core/src/Exception/InvalidShortCodeException.php +++ b/module/Core/src/Exception/InvalidShortCodeException.php @@ -5,7 +5,7 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException; class InvalidShortCodeException extends RuntimeException { - public static function fromShortCode($shortCode, $charSet, \Exception $previous = null) + public static function fromCharset($shortCode, $charSet, \Exception $previous = null) { $code = isset($previous) ? $previous->getCode() : -1; return new static( @@ -14,4 +14,9 @@ class InvalidShortCodeException extends RuntimeException $previous ); } + + public static function fromNotFoundShortCode($shortCode) + { + return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode)); + } } diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 84552115..60845b88 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -5,11 +5,15 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Zend\Paginator\Paginator; class ShortUrlService implements ShortUrlServiceInterface { + use TagManagerTrait; + /** * @var EntityManagerInterface */ @@ -40,4 +44,26 @@ class ShortUrlService implements ShortUrlServiceInterface return $paginator; } + + /** + * @param string $shortCode + * @param string[] $tags + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function setTagsByShortCode($shortCode, array $tags = []) + { + /** @var ShortUrl $shortUrl */ + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ + 'shortCode' => $shortCode, + ]); + if (! isset($shortUrl)) { + throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); + } + + $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); + $this->em->flush(); + + return $shortUrl; + } } diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index c6885c39..5ad304ee 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Zend\Paginator\Paginator; interface ShortUrlServiceInterface @@ -11,4 +12,12 @@ interface ShortUrlServiceInterface * @return ShortUrl[]|Paginator */ public function listShortUrls($page = 1); + + /** + * @param string $shortCode + * @param string[] $tags + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function setTagsByShortCode($shortCode, array $tags = []); } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index ed87b604..bc422cab 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -12,9 +12,12 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Util\TagManagerTrait; class UrlShortener implements UrlShortenerInterface { + use TagManagerTrait; + const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ'; /** @@ -59,15 +62,16 @@ class UrlShortener implements UrlShortenerInterface * Creates and persists a unique shortcode generated for provided url * * @param UriInterface $url + * @param string[] $tags * @return string * @throws InvalidUrlException * @throws RuntimeException */ - public function urlToShortCode(UriInterface $url) + public function urlToShortCode(UriInterface $url, array $tags = []) { // If the url already exists in the database, just return its short code $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ - 'originalUrl' => $url + 'originalUrl' => $url, ]); if (isset($shortUrl)) { return $shortUrl->getShortCode(); @@ -88,7 +92,8 @@ class UrlShortener implements UrlShortenerInterface // Generate the short code and persist it $shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId()); - $shortUrl->setShortCode($shortCode); + $shortUrl->setShortCode($shortCode) + ->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); $this->em->commit(); @@ -156,7 +161,7 @@ class UrlShortener implements UrlShortenerInterface // Validate short code format if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) { - throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars); + throw InvalidShortCodeException::fromCharset($shortCode, $this->chars); } /** @var ShortUrl $shortUrl */ diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 07d45401..4ddf4b7b 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -12,11 +12,12 @@ interface UrlShortenerInterface * Creates and persists a unique shortcode generated for provided url * * @param UriInterface $url + * @param string[] $tags * @return string * @throws InvalidUrlException * @throws RuntimeException */ - public function urlToShortCode(UriInterface $url); + public function urlToShortCode(UriInterface $url, array $tags = []); /** * Tries to find the mapped URL for provided short code. Returns null if not found diff --git a/module/Core/src/Util/TagManagerTrait.php b/module/Core/src/Util/TagManagerTrait.php new file mode 100644 index 00000000..9ca02b92 --- /dev/null +++ b/module/Core/src/Util/TagManagerTrait.php @@ -0,0 +1,38 @@ +normalizeTagName($tagName); + $tag = $em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?: (new Tag())->setName($tagName); + $em->persist($tag); + $entities[] = $tag; + } + + return new Collections\ArrayCollection($entities); + } + + /** + * Tag names are trimmed, lowercased and spaces are replaced by dashes + * + * @param string $tagName + * @return string + */ + protected function normalizeTagName($tagName) + { + return str_replace(' ', '-', strtolower(trim($tagName))); + } +} diff --git a/module/Core/test/Entity/TagTest.php b/module/Core/test/Entity/TagTest.php new file mode 100644 index 00000000..b41d7a36 --- /dev/null +++ b/module/Core/test/Entity/TagTest.php @@ -0,0 +1,18 @@ +setName('This is my name'); + $this->assertEquals($tag->getName(), $tag->jsonSerialize()); + } +} diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 1244d3a5..a87f91e1 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -2,10 +2,12 @@ namespace ShlinkioTest\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrlService; @@ -23,6 +25,8 @@ class ShortUrlServiceTest extends TestCase public function setUp() { $this->em = $this->prophesize(EntityManagerInterface::class); + $this->em->persist(Argument::any())->willReturn(null); + $this->em->flush()->willReturn(null); $this->service = new ShortUrlService($this->em->reveal()); } @@ -46,4 +50,40 @@ class ShortUrlServiceTest extends TestCase $list = $this->service->listShortUrls(); $this->assertEquals(4, $list->getCurrentItemCount()); } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Core\Exception\InvalidShortCodeException + */ + public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode() + { + $shortCode = 'abc123'; + $repo = $this->prophesize(ShortUrlRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn(null) + ->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $this->service->setTagsByShortCode($shortCode); + } + + /** + * @test + */ + public function providedTagsAreGetFromRepoAndSetToTheShortUrl() + { + $shortUrl = $this->prophesize(ShortUrl::class); + $shortUrl->setTags(Argument::any())->shouldBeCalledTimes(1); + $shortCode = 'abc123'; + $repo = $this->prophesize(ShortUrlRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl->reveal()) + ->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $tagRepo = $this->prophesize(EntityRepository::class); + $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag())->shouldbeCalledTimes(1); + $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldbeCalledTimes(1); + $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); + + $this->service->setTagsByShortCode($shortCode, ['foo', 'bar']); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 685de2c3..a6b51cb7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -18,7 +18,9 @@ return [ Action\ResolveUrlAction::class => AnnotatedFactory::class, Action\GetVisitsAction::class => AnnotatedFactory::class, Action\ListShortcodesAction::class => AnnotatedFactory::class, + Action\EditTagsAction::class => AnnotatedFactory::class, + Middleware\BodyParserMiddleware::class => AnnotatedFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, ], diff --git a/module/Rest/config/middleware-pipeline.config.php b/module/Rest/config/middleware-pipeline.config.php index 78c20c38..817fc568 100644 --- a/module/Rest/config/middleware-pipeline.config.php +++ b/module/Rest/config/middleware-pipeline.config.php @@ -7,6 +7,7 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ + Middleware\BodyParserMiddleware::class, Middleware\CheckAuthenticationMiddleware::class, Middleware\CrossDomainMiddleware::class, ], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 4cc0f510..d27b9c6d 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -34,6 +34,12 @@ return [ 'middleware' => Action\GetVisitsAction::class, 'allowed_methods' => ['GET', 'OPTIONS'], ], + [ + 'name' => 'rest-edit-tags', + 'path' => '/rest/short-codes/{shortCode}/tags', + 'middleware' => Action\EditTagsAction::class, + 'allowed_methods' => ['PUT', 'OPTIONS'], + ], ], ]; diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index a8a16fdf..070394c0 100644 Binary files a/module/Rest/lang/es.mo and b/module/Rest/lang/es.mo differ diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po index 9da7489f..b824cf72 100644 --- a/module/Rest/lang/es.po +++ b/module/Rest/lang/es.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-08-18 17:27+0200\n" -"PO-Revision-Date: 2016-08-18 17:27+0200\n" +"POT-Creation-Date: 2016-08-21 18:17+0200\n" +"PO-Revision-Date: 2016-08-21 18:17+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -35,14 +35,17 @@ msgstr "La URL proporcionada \"%s\" es inválida. Prueba con una diferente." msgid "Unexpected error occurred" msgstr "Ocurrió un error inesperado" -#, php-format -msgid "Provided short code %s does not exist" -msgstr "El código corto \"%s\" proporcionado no existe" +msgid "A list of tags was not provided" +msgstr "No se ha proporcionado una lista de etiquetas" #, php-format msgid "No URL found for short code \"%s\"" msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" +#, php-format +msgid "Provided short code %s does not exist" +msgstr "El código corto \"%s\" proporcionado no existe" + #, php-format msgid "Provided short code \"%s\" has an invalid format" msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido" diff --git a/module/Rest/src/Action/CreateShortcodeAction.php b/module/Rest/src/Action/CreateShortcodeAction.php index 5dd1221e..fdecedef 100644 --- a/module/Rest/src/Action/CreateShortcodeAction.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -16,7 +16,7 @@ use Zend\I18n\Translator\TranslatorInterface; class CreateShortcodeAction extends AbstractRestAction { /** - * @var UrlShortener|UrlShortenerInterface + * @var UrlShortenerInterface */ private $urlShortener; /** @@ -31,7 +31,7 @@ class CreateShortcodeAction extends AbstractRestAction /** * GenerateShortcodeMiddleware constructor. * - * @param UrlShortenerInterface|UrlShortener $urlShortener + * @param UrlShortenerInterface $urlShortener * @param TranslatorInterface $translator * @param array $domainConfig * @param LoggerInterface|null $logger @@ -66,9 +66,10 @@ class CreateShortcodeAction extends AbstractRestAction ], 400); } $longUrl = $postData['longUrl']; + $tags = isset($postData['tags']) && is_array($postData['tags']) ? $postData['tags'] : []; try { - $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags); $shortUrl = (new Uri())->withPath($shortCode) ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); diff --git a/module/Rest/src/Action/EditTagsAction.php b/module/Rest/src/Action/EditTagsAction.php new file mode 100644 index 00000000..3dd76333 --- /dev/null +++ b/module/Rest/src/Action/EditTagsAction.php @@ -0,0 +1,73 @@ +shortUrlService = $shortUrlService; + $this->translator = $translator; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + protected function dispatch(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + $bodyParams = $request->getParsedBody(); + + if (! isset($bodyParams['tags'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => $this->translator->translate('A list of tags was not provided'), + ], 400); + } + $tags = $bodyParams['tags']; + + try { + $shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags); + return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); + } catch (InvalidShortCodeException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), + ], 404); + } + } +} diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php new file mode 100644 index 00000000..598f53ca --- /dev/null +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -0,0 +1,52 @@ +getMethod(); + if (! in_array($method, ['PUT', 'PATCH'])) { + return $out($request, $response); + } + + $contentType = $request->getHeaderLine('Content-type'); + $rawBody = (string) $request->getBody(); + if (in_array($contentType, ['application/json', 'text/json', 'application/x-json'])) { + return $out($request->withParsedBody(json_decode($rawBody, true)), $response); + } + + $parsedBody = []; + parse_str($rawBody, $parsedBody); + return $out($request->withParsedBody($parsedBody), $response); + } +} diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index 3019badf..4327df9e 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -48,7 +48,7 @@ class CrossDomainMiddleware implements MiddlewareInterface // 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 based on path 'Access-Control-Max-Age' => '1000', 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), ] as $key => $value) { diff --git a/module/Rest/test/Action/CreateShortcodeActionTest.php b/module/Rest/test/Action/CreateShortcodeActionTest.php index dcc56bf6..c3b9e3ff 100644 --- a/module/Rest/test/Action/CreateShortcodeActionTest.php +++ b/module/Rest/test/Action/CreateShortcodeActionTest.php @@ -47,8 +47,9 @@ class CreateShortcodeActionTest extends TestCase */ public function properShortcodeConversionReturnsData() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willReturn('abc123') - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willReturn('abc123') + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -63,8 +64,9 @@ class CreateShortcodeActionTest extends TestCase */ public function anInvalidUrlReturnsError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(InvalidUrlException::class) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willThrow(InvalidUrlException::class) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -79,8 +81,9 @@ class CreateShortcodeActionTest extends TestCase */ public function aGenericExceptionWillReturnError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(\Exception::class) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', diff --git a/module/Rest/test/Action/EditTagsActionTest.php b/module/Rest/test/Action/EditTagsActionTest.php new file mode 100644 index 00000000..b0813519 --- /dev/null +++ b/module/Rest/test/Action/EditTagsActionTest.php @@ -0,0 +1,76 @@ +shortUrlService = $this->prophesize(ShortUrlService::class); + $this->action = new EditTagsAction($this->shortUrlService->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function notProvidingTagsReturnsError() + { + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123'), + new Response() + ); + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function anInvalidShortCodeReturnsNotFound() + { + $shortCode = 'abc123'; + $this->shortUrlService->setTagsByShortCode($shortCode, [])->willThrow(InvalidShortCodeException::class) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), + new Response() + ); + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function tagsListIsReturnedIfCorrectShortCodeIsProvided() + { + $shortCode = 'abc123'; + $this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl()) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), + new Response() + ); + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php new file mode 100644 index 00000000..17c3057b --- /dev/null +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -0,0 +1,79 @@ +middleware = new BodyParserMiddleware(); + } + + /** + * @test + */ + public function requestsFromOtherMethodsJustFallbackToNextMiddleware() + { + $request = ServerRequestFactory::fromGlobals()->withMethod('GET'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function ($req, $resp) use ($test, $request) { + $test->assertSame($request, $req); + }); + + $request = $request->withMethod('POST'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function ($req, $resp) use ($test, $request) { + $test->assertSame($request, $req); + }); + } + + /** + * @test + */ + public function jsonRequestsAreJsonDecoded() + { + $body = new Stream('php://temp', 'wr'); + $body->write('{"foo": "bar", "bar": ["one", 5]}'); + $request = ServerRequestFactory::fromGlobals()->withMethod('PUT') + ->withBody($body) + ->withHeader('content-type', 'application/json'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function (Request $req, $resp) use ($test, $request) { + $test->assertNotSame($request, $req); + $test->assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); + }); + } + + /** + * @test + */ + public function regularRequestsAreUrlDecoded() + { + $body = new Stream('php://temp', 'wr'); + $body->write('foo=bar&bar[]=one&bar[]=5'); + $request = ServerRequestFactory::fromGlobals()->withMethod('PUT') + ->withBody($body); + $test = $this; + $this->middleware->__invoke($request, new Response(), function (Request $req, $resp) use ($test, $request) { + $test->assertNotSame($request, $req); + $test->assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); + }); + } +}