diff --git a/composer.json b/composer.json index 7dd1bef4..31854436 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "require": { "php": "^7.0", "acelaya/ze-content-based-error-handler": "^2.0", + "cocur/slugify": "^3.0", "doctrine/annotations": "^1.4 <1.5", "doctrine/cache": "^1.6 <1.7", "doctrine/collections": "^1.4 <1.5", diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php new file mode 100644 index 00000000..4f76b725 --- /dev/null +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -0,0 +1,14 @@ +httpClient = $httpClient; $this->em = $em; $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; $this->cache = $cache; + $this->slugger = $slugger ?: new Slugify(); } /** @@ -59,7 +68,9 @@ class UrlShortener implements UrlShortenerInterface * @param string[] $tags * @param \DateTime|null $validSince * @param \DateTime|null $validUntil + * @param string|null $customSlug * @return string + * @throws NonUniqueSlugException * @throws InvalidUrlException * @throws RuntimeException */ @@ -67,7 +78,8 @@ class UrlShortener implements UrlShortenerInterface UriInterface $url, array $tags = [], \DateTime $validSince = null, - \DateTime $validUntil = null + \DateTime $validUntil = null, + string $customSlug = null ): string { // If the url already exists in the database, just return its short code $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ @@ -79,6 +91,8 @@ class UrlShortener implements UrlShortenerInterface // Check that the URL exists $this->checkUrlExists($url); + $customSlug = $this->processCustomSlug($customSlug); + // Transactionally insert the short url, then generate the short code and finally update the short code try { @@ -93,7 +107,7 @@ class UrlShortener implements UrlShortenerInterface $this->em->flush(); // Generate the short code and persist it - $shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId()); + $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode($shortUrl->getId()); $shortUrl->setShortCode($shortCode) ->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); @@ -116,7 +130,7 @@ class UrlShortener implements UrlShortenerInterface * @param UriInterface $url * @return void */ - protected function checkUrlExists(UriInterface $url) + private function checkUrlExists(UriInterface $url) { try { $this->httpClient->request('GET', $url, ['allow_redirects' => [ @@ -133,7 +147,7 @@ class UrlShortener implements UrlShortenerInterface * @param int $id * @return string */ - protected function convertAutoincrementIdToShortCode($id) + private function convertAutoincrementIdToShortCode($id) { $id = ((int) $id) + 200000; // Increment the Id so that the generated shortcode is not too short $length = strlen($this->chars); @@ -148,6 +162,22 @@ class UrlShortener implements UrlShortenerInterface return $this->chars[(int) $id] . $code; } + private function processCustomSlug($customSlug) + { + if ($customSlug === null) { + return null; + } + + // If a custom slug was provided, check it is unique + $customSlug = $this->slugger->slugify($customSlug); + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]); + if ($shortUrl !== null) { + throw NonUniqueSlugException::fromSlug($customSlug); + } + + return $customSlug; + } + /** * Tries to find the mapped URL for provided short code. Returns null if not found * diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 27b0574a..aa346756 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -8,6 +8,7 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; interface UrlShortenerInterface { @@ -18,7 +19,9 @@ interface UrlShortenerInterface * @param string[] $tags * @param \DateTime|null $validSince * @param \DateTime|null $validUntil + * @param string|null $customSlug * @return string + * @throws NonUniqueSlugException * @throws InvalidUrlException * @throws RuntimeException */ @@ -26,7 +29,8 @@ interface UrlShortenerInterface UriInterface $url, array $tags = [], \DateTime $validSince = null, - \DateTime $validUntil = null + \DateTime $validUntil = null, + string $customSlug = null ): string; /** diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 89aeb44e..7a1c93c6 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Service; +use Cocur\Slugify\SlugifyInterface; use Doctrine\Common\Cache\ArrayCache; use Doctrine\Common\Cache\Cache; use Doctrine\Common\Persistence\ObjectRepository; @@ -14,8 +15,10 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Request; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\UrlShortener; use Zend\Diactoros\Uri; @@ -38,6 +41,10 @@ class UrlShortenerTest extends TestCase * @var Cache */ protected $cache; + /** + * @var ObjectProphecy + */ + protected $slugger; public function setUp() { @@ -60,8 +67,15 @@ class UrlShortenerTest extends TestCase $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->cache = new ArrayCache(); + $this->slugger = $this->prophesize(SlugifyInterface::class); - $this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal(), $this->cache); + $this->urlShortener = new UrlShortener( + $this->httpClient->reveal(), + $this->em->reveal(), + $this->cache, + UrlShortener::DEFAULT_CHARS, + $this->slugger->reveal() + ); } /** @@ -117,6 +131,54 @@ class UrlShortenerTest extends TestCase $this->assertEquals($shortUrl->getShortCode(), $shortCode); } + /** + * @test + */ + public function whenCustomSlugIsProvidedItIsUsed() + { + /** @var MethodProphecy $slugify */ + $slugify = $this->slugger->slugify('custom-slug')->willReturnArgument(); + + $this->urlShortener->urlToShortCode( + new Uri('http://foobar.com/12345/hello?foo=bar'), + [], + null, + null, + 'custom-slug' + ); + + $slugify->shouldHaveBeenCalledTimes(1); + } + + /** + * @test + */ + public function exceptionIsThrownWhenNonUniqueSlugIsProvided() + { + /** @var MethodProphecy $slugify */ + $slugify = $this->slugger->slugify('custom-slug')->willReturnArgument(); + + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + /** @var MethodProphecy $findBySlug */ + $findBySlug = $repo->findOneBy(['shortCode' => 'custom-slug'])->willReturn(new ShortUrl()); + $repo->findOneBy(Argument::cetera())->willReturn(null); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $slugify->shouldBeCalledTimes(1); + $findBySlug->shouldBeCalledTimes(1); + $getRepo->shouldBeCalled(); + $this->expectException(NonUniqueSlugException::class); + + $this->urlShortener->urlToShortCode( + new Uri('http://foobar.com/12345/hello?foo=bar'), + [], + null, + null, + 'custom-slug' + ); + } + /** * @test */