Pārlūkot izejas kodu

Merge pull request #995 from acelaya-forks/feature/improve-url-relations

Feature/improve url relations
Alejandro Celaya 3 gadi atpakaļ
vecāks
revīzija
e30c9c86ff
36 mainītis faili ar 368 papildinājumiem un 255 dzēšanām
  1. 4 0
      CHANGELOG.md
  2. 34 2
      docs/swagger/paths/v1_short-urls_{shortCode}.json
  3. 2 1
      docs/swagger/paths/v1_short-urls_{shortCode}_tags.json
  4. 6 1
      module/Core/config/dependencies.config.php
  5. 10 12
      module/Core/src/Entity/ShortUrl.php
  6. 0 5
      module/Core/src/Importer/ImportedLinksProcessor.php
  7. 17 0
      module/Core/src/Model/ShortUrlEdit.php
  8. 7 20
      module/Core/src/Service/ShortUrlService.php
  9. 1 7
      module/Core/src/Service/ShortUrlServiceInterface.php
  10. 0 4
      module/Core/src/Service/UrlShortener.php
  11. 24 0
      module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php
  12. 8 0
      module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php
  13. 14 0
      module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php
  14. 8 10
      module/Core/src/Util/TagManagerTrait.php
  15. 7 0
      module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
  16. 24 26
      module/Core/test-db/Repository/ShortUrlRepositoryTest.php
  17. 33 38
      module/Core/test-db/Repository/TagRepositoryTest.php
  18. 25 35
      module/Core/test-db/Repository/VisitRepositoryTest.php
  19. 4 25
      module/Core/test/Service/ShortUrlServiceTest.php
  20. 3 4
      module/Core/test/Service/UrlShortenerTest.php
  21. 41 0
      module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php
  22. 12 0
      module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php
  23. 1 1
      module/Rest/config/dependencies.config.php
  24. 3 5
      module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php
  25. 8 4
      module/Rest/src/Action/ShortUrl/EditShortUrlAction.php
  26. 6 1
      module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php
  27. 3 5
      module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php
  28. 3 4
      module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php
  29. 14 4
      module/Rest/test-api/Action/CreateShortUrlTest.php
  30. 19 0
      module/Rest/test-api/Action/EditShortUrlTagsTest.php
  31. 4 4
      module/Rest/test-api/Action/EditShortUrlTest.php
  32. 1 1
      module/Rest/test-api/Action/ResolveShortUrlTest.php
  33. 13 6
      module/Rest/test-api/Fixtures/ShortUrlsFixture.php
  34. 3 25
      module/Rest/test-api/Fixtures/TagsFixture.php
  35. 3 3
      module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php
  36. 3 2
      module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php

+ 4 - 0
CHANGELOG.md

@@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
 
     All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0
 
+* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`).
+
+    The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead.
+
 ### Removed
 * *Nothing*
 

+ 34 - 2
docs/swagger/paths/v1_short-urls_{shortCode}.json

@@ -131,6 +131,13 @@
                             "validateUrl": {
                                 "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
                                 "type": "boolean"
+                            },
+                            "tags": {
+                                "type": "array",
+                                "items": {
+                                    "type": "string"
+                                },
+                                "description": "The list of tags to set to the short URL."
                             }
                         }
                     }
@@ -143,8 +150,33 @@
             }
         ],
         "responses": {
-            "204": {
-                "description": "The short code has been properly updated."
+            "200": {
+                "description": "The short URL has been properly updated.",
+                "content": {
+                    "application/json": {
+                        "schema": {
+                            "$ref": "../definitions/ShortUrl.json"
+                        }
+                    }
+                },
+                "examples": {
+                    "application/json": {
+                        "shortCode": "12Kb3",
+                        "shortUrl": "https://doma.in/12Kb3",
+                        "longUrl": "https://shlink.io",
+                        "dateCreated": "2016-05-01T20:34:16+02:00",
+                        "visitsCount": 1029,
+                        "tags": [
+                            "shlink"
+                        ],
+                        "meta": {
+                            "validSince": "2017-01-21T00:00:00+02:00",
+                            "validUntil": null,
+                            "maxVisits": 100
+                        },
+                        "domain": null
+                    }
+                }
             },
             "400": {
                 "description": "Provided meta arguments are invalid.",

+ 2 - 1
docs/swagger/paths/v1_short-urls_{shortCode}_tags.json

@@ -1,11 +1,12 @@
 {
     "put": {
+        "deprecated": true,
         "operationId": "editShortUrlTags",
         "tags": [
             "Short URLs"
         ],
         "summary": "Edit tags on short URL",
-        "description": "Edit the tags on URL identified by provided short code.",
+        "description": "Edit the tags on URL identified by provided short code.<br />This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.",
         "parameters": [
             {
                 "$ref": "../parameters/version.json"

+ 6 - 1
module/Core/config/dependencies.config.php

@@ -77,7 +77,12 @@ return [
             EventDispatcherInterface::class,
             'config.url_shortener.anonymize_remote_addr',
         ],
-        Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
+        Service\ShortUrlService::class => [
+            'em',
+            Service\ShortUrl\ShortUrlResolver::class,
+            Util\UrlValidator::class,
+            ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
+        ],
         Visit\VisitLocator::class => ['em'],
         Visit\VisitsStatsHelper::class => ['em'],
         Tag\TagService::class => ['em'],

+ 10 - 12
module/Core/src/Entity/ShortUrl.php

@@ -64,7 +64,7 @@ class ShortUrl extends AbstractEntity
         $instance->longUrl = $meta->getLongUrl();
         $instance->dateCreated = Chronos::now();
         $instance->visits = new ArrayCollection();
-        $instance->tags = new ArrayCollection();
+        $instance->tags = $relationResolver->resolveTags($meta->getTags());
         $instance->validSince = $meta->getValidSince();
         $instance->validUntil = $meta->getValidUntil();
         $instance->maxVisits = $meta->getMaxVisits();
@@ -85,6 +85,7 @@ class ShortUrl extends AbstractEntity
         $meta = [
             ShortUrlInputFilter::LONG_URL => $url->longUrl(),
             ShortUrlInputFilter::DOMAIN => $url->domain(),
+            ShortUrlInputFilter::TAGS => $url->tags(),
             ShortUrlInputFilter::VALIDATE_URL => false,
         ];
         if ($importShortCode) {
@@ -127,17 +128,10 @@ class ShortUrl extends AbstractEntity
         return $this->tags;
     }
 
-    /**
-     * @param Collection|Tag[] $tags
-     */
-    public function setTags(Collection $tags): self
-    {
-        $this->tags = $tags;
-        return $this;
-    }
-
-    public function update(ShortUrlEdit $shortUrlEdit): void
-    {
+    public function update(
+        ShortUrlEdit $shortUrlEdit,
+        ?ShortUrlRelationResolverInterface $relationResolver = null
+    ): void {
         if ($shortUrlEdit->hasValidSince()) {
             $this->validSince = $shortUrlEdit->validSince();
         }
@@ -150,6 +144,10 @@ class ShortUrl extends AbstractEntity
         if ($shortUrlEdit->hasLongUrl()) {
             $this->longUrl = $shortUrlEdit->longUrl();
         }
+        if ($shortUrlEdit->hasTags()) {
+            $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
+            $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags());
+        }
     }
 
     /**

+ 0 - 5
module/Core/src/Importer/ImportedLinksProcessor.php

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
 use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
 use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
 use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
 use Symfony\Component\Console\Style\StyleInterface;
@@ -19,8 +18,6 @@ use function sprintf;
 
 class ImportedLinksProcessor implements ImportedLinksProcessorInterface
 {
-    use TagManagerTrait;
-
     private EntityManagerInterface $em;
     private ShortUrlRelationResolverInterface $relationResolver;
     private ShortCodeHelperInterface $shortCodeHelper;
@@ -59,8 +56,6 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
             }
 
             $shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
-            $shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags()));
-
             if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
                 continue;
             }

+ 17 - 0
module/Core/src/Model/ShortUrlEdit.php

@@ -23,6 +23,8 @@ final class ShortUrlEdit
     private ?Chronos $validUntil = null;
     private bool $maxVisitsPropWasProvided = false;
     private ?int $maxVisits = null;
+    private bool $tagsPropWasProvided = false;
+    private array $tags = [];
     private ?bool $validateUrl = null;
 
     private function __construct()
@@ -53,12 +55,14 @@ final class ShortUrlEdit
         $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data);
         $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data);
         $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data);
+        $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
 
         $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
         $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
         $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
         $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
         $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL);
+        $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
     }
 
     public function longUrl(): ?string
@@ -101,6 +105,19 @@ final class ShortUrlEdit
         return $this->maxVisitsPropWasProvided;
     }
 
+    /**
+     * @return string[]
+     */
+    public function tags(): array
+    {
+        return $this->tags;
+    }
+
+    public function hasTags(): bool
+    {
+        return $this->tagsPropWasProvided;
+    }
+
     public function doValidateUrl(): ?bool
     {
         return $this->validateUrl;

+ 7 - 20
module/Core/src/Service/ShortUrlService.php

@@ -15,26 +15,27 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
 use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
+use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
 use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
 
 class ShortUrlService implements ShortUrlServiceInterface
 {
-    use TagManagerTrait;
-
     private ORM\EntityManagerInterface $em;
     private ShortUrlResolverInterface $urlResolver;
     private UrlValidatorInterface $urlValidator;
+    private ShortUrlRelationResolverInterface $relationResolver;
 
     public function __construct(
         ORM\EntityManagerInterface $em,
         ShortUrlResolverInterface $urlResolver,
-        UrlValidatorInterface $urlValidator
+        UrlValidatorInterface $urlValidator,
+        ShortUrlRelationResolverInterface $relationResolver
     ) {
         $this->em = $em;
         $this->urlResolver = $urlResolver;
         $this->urlValidator = $urlValidator;
+        $this->relationResolver = $relationResolver;
     }
 
     /**
@@ -51,25 +52,11 @@ class ShortUrlService implements ShortUrlServiceInterface
         return $paginator;
     }
 
-    /**
-     * @param string[] $tags
-     * @throws ShortUrlNotFoundException
-     */
-    public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl
-    {
-        $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
-        $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
-
-        $this->em->flush();
-
-        return $shortUrl;
-    }
-
     /**
      * @throws ShortUrlNotFoundException
      * @throws InvalidUrlException
      */
-    public function updateMetadataByShortCode(
+    public function updateShortUrl(
         ShortUrlIdentifier $identifier,
         ShortUrlEdit $shortUrlEdit,
         ?ApiKey $apiKey = null
@@ -79,7 +66,7 @@ class ShortUrlService implements ShortUrlServiceInterface
         }
 
         $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
-        $shortUrl->update($shortUrlEdit);
+        $shortUrl->update($shortUrlEdit, $this->relationResolver);
 
         $this->em->flush();
 

+ 1 - 7
module/Core/src/Service/ShortUrlServiceInterface.php

@@ -20,17 +20,11 @@ interface ShortUrlServiceInterface
      */
     public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
 
-    /**
-     * @param string[] $tags
-     * @throws ShortUrlNotFoundException
-     */
-    public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl;
-
     /**
      * @throws ShortUrlNotFoundException
      * @throws InvalidUrlException
      */
-    public function updateMetadataByShortCode(
+    public function updateShortUrl(
         ShortUrlIdentifier $identifier,
         ShortUrlEdit $shortUrlEdit,
         ?ApiKey $apiKey = null

+ 0 - 4
module/Core/src/Service/UrlShortener.php

@@ -12,14 +12,11 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
 use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
 use Throwable;
 
 class UrlShortener implements UrlShortenerInterface
 {
-    use TagManagerTrait;
-
     private EntityManagerInterface $em;
     private UrlValidatorInterface $urlValidator;
     private ShortUrlRelationResolverInterface $relationResolver;
@@ -54,7 +51,6 @@ class UrlShortener implements UrlShortenerInterface
 
         return $this->em->transactional(function () use ($meta) {
             $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver);
-            $shortUrl->setTags($this->tagNamesToEntities($this->em, $meta->getTags()));
 
             $this->verifyShortCodeUniqueness($meta, $shortUrl);
             $this->em->persist($shortUrl);

+ 24 - 0
module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php

@@ -4,8 +4,13 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
 
+use Doctrine\Common\Collections;
+use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\EntityManagerInterface;
 use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Entity\Tag;
+
+use function Functional\map;
 
 class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
 {
@@ -26,4 +31,23 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
         $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
         return $existingDomain ?? new Domain($domain);
     }
+
+    /**
+     * @param string[] $tags
+     * @return Collection|Tag[]
+     */
+    public function resolveTags(array $tags): Collections\Collection
+    {
+        if (empty($tags)) {
+            return new Collections\ArrayCollection();
+        }
+
+        $repo = $this->em->getRepository(Tag::class);
+        return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag {
+            $tag = $repo->findOneBy(['name' => $tagName]) ?? new Tag($tagName);
+            $this->em->persist($tag);
+
+            return $tag;
+        }));
+    }
 }

+ 8 - 0
module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php

@@ -4,9 +4,17 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
 
+use Doctrine\Common\Collections\Collection;
 use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Entity\Tag;
 
 interface ShortUrlRelationResolverInterface
 {
     public function resolveDomain(?string $domain): ?Domain;
+
+    /**
+     * @param string[] $tags
+     * @return Collection|Tag[]
+     */
+    public function resolveTags(array $tags): Collection;
 }

+ 14 - 0
module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php

@@ -4,7 +4,12 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
 
+use Doctrine\Common\Collections;
+use Doctrine\Common\Collections\Collection;
 use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Entity\Tag;
+
+use function Functional\map;
 
 class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface
 {
@@ -12,4 +17,13 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
     {
         return $domain !== null ? new Domain($domain) : null;
     }
+
+    /**
+     * @param string[] $tags
+     * @return Collection|Tag[]
+     */
+    public function resolveTags(array $tags): Collections\Collection
+    {
+        return new Collections\ArrayCollection(map($tags, fn (string $tag) => new Tag($tag)));
+    }
 }

+ 8 - 10
module/Core/src/Util/TagManagerTrait.php

@@ -7,22 +7,25 @@ namespace Shlinkio\Shlink\Core\Util;
 use Doctrine\Common\Collections;
 use Doctrine\ORM\EntityManagerInterface;
 use Shlinkio\Shlink\Core\Entity\Tag;
+use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
 
 use function Functional\map;
-use function str_replace;
-use function strtolower;
-use function trim;
 
+/** @deprecated */
 trait TagManagerTrait
 {
     /**
      * @param string[] $tags
+     * @deprecated
      * @return Collections\Collection|Tag[]
      */
     private function tagNamesToEntities(EntityManagerInterface $em, array $tags): Collections\Collection
     {
-        $entities = map($tags, function (string $tagName) use ($em) {
-            $tagName = $this->normalizeTagName($tagName);
+        $normalizedTags = ShortUrlInputFilter::withNonRequiredLongUrl([
+            ShortUrlInputFilter::TAGS => $tags,
+        ])->getValue(ShortUrlInputFilter::TAGS);
+
+        $entities = map($normalizedTags, function (string $tagName) use ($em) {
             $tag = $em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?? new Tag($tagName);
             $em->persist($tag);
 
@@ -31,9 +34,4 @@ trait TagManagerTrait
 
         return new Collections\ArrayCollection($entities);
     }
-
-    private function normalizeTagName(string $tagName): string
-    {
-        return str_replace(' ', '-', strtolower(trim($tagName)));
-    }
 }

+ 7 - 0
module/Core/test-db/Domain/Repository/DomainRepositoryTest.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace ShlinkioTest\Shlink\Core\Domain\Repository;
 
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
 use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
 use Shlinkio\Shlink\Core\Entity\Domain;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -102,6 +104,11 @@ class DomainRepositoryTest extends DatabaseTestCase
                 {
                     return $this->domain;
                 }
+
+                public function resolveTags(array $tags): Collection
+                {
+                    return new ArrayCollection();
+                }
             },
         );
     }

+ 24 - 26
module/Core/test-db/Repository/ShortUrlRepositoryTest.php

@@ -10,14 +10,12 @@ use ReflectionObject;
 use Shlinkio\Shlink\Common\Util\DateRange;
 use Shlinkio\Shlink\Core\Entity\Domain;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\Entity\Tag;
 use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
 use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
 use Shlinkio\Shlink\Core\Model\Visitor;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
 use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
 use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -27,13 +25,13 @@ use function count;
 
 class ShortUrlRepositoryTest extends DatabaseTestCase
 {
-    use TagManagerTrait;
-
     private ShortUrlRepository $repo;
+    private PersistenceShortUrlRelationResolver $relationResolver;
 
     public function beforeEach(): void
     {
         $this->repo = $this->getEntityManager()->getRepository(ShortUrl::class);
+        $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
     }
 
     /** @test */
@@ -90,11 +88,10 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
     /** @test */
     public function findListProperlyFiltersResult(): void
     {
-        $tag = new Tag('bar');
-        $this->getEntityManager()->persist($tag);
-
-        $foo = ShortUrl::withLongUrl('foo');
-        $foo->setTags(new ArrayCollection([$tag]));
+        $foo = ShortUrl::fromMeta(
+            ShortUrlMeta::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]),
+            $this->relationResolver,
+        );
         $this->getEntityManager()->persist($foo);
 
         $bar = ShortUrl::withLongUrl('bar');
@@ -235,8 +232,10 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
         $start = Chronos::parse('2020-03-05 20:18:30');
         $end = Chronos::parse('2021-03-05 20:18:30');
 
-        $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo']));
-        $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar']));
+        $shortUrl = ShortUrl::fromMeta(
+            ShortUrlMeta::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]),
+            $this->relationResolver,
+        );
         $this->getEntityManager()->persist($shortUrl);
 
         $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['validUntil' => $end, 'longUrl' => 'bar']));
@@ -300,28 +299,24 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
     public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void
     {
         $start = Chronos::parse('2020-03-05 20:18:30');
-        $meta = ShortUrlMeta::fromRawData(['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo']);
         $tags = ['foo', 'bar'];
-        $tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags);
-        $metaWithTags = ShortUrlMeta::fromRawData(
+        $meta = ShortUrlMeta::fromRawData(
             ['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo', 'tags' => $tags],
         );
 
-        $shortUrl1 = ShortUrl::fromMeta($meta);
-        $shortUrl1->setTags($tagEntities);
+        $shortUrl1 = ShortUrl::fromMeta($meta, $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl1);
+        $this->getEntityManager()->flush();
 
-        $shortUrl2 = ShortUrl::fromMeta($meta);
-        $shortUrl2->setTags($tagEntities);
+        $shortUrl2 = ShortUrl::fromMeta($meta, $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl2);
+        $this->getEntityManager()->flush();
 
-        $shortUrl3 = ShortUrl::fromMeta($meta);
-        $shortUrl3->setTags($tagEntities);
+        $shortUrl3 = ShortUrl::fromMeta($meta, $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl3);
-
         $this->getEntityManager()->flush();
 
-        $result = $this->repo->findOneMatching($metaWithTags);
+        $result = $this->repo->findOneMatching($meta);
 
         self::assertSame($shortUrl1, $result);
         self::assertNotSame($shortUrl2, $result);
@@ -349,10 +344,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
         $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain));
         $this->getEntityManager()->persist($rightDomainApiKey);
 
-        $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(
-            ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority(), 'longUrl' => 'foo'],
-        ), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
-        $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar']));
+        $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+            'validSince' => $start,
+            'apiKey' => $apiKey,
+            'domain' => $rightDomain->getAuthority(),
+            'longUrl' => 'foo',
+            'tags' => ['foo', 'bar'],
+        ]), $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl);
 
         $this->getEntityManager()->flush();

+ 33 - 38
module/Core/test-db/Repository/TagRepositoryTest.php

@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace ShlinkioTest\Shlink\Core\Repository;
 
-use Doctrine\Common\Collections\ArrayCollection;
 use Shlinkio\Shlink\Core\Entity\Domain;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
 use Shlinkio\Shlink\Core\Entity\Tag;
@@ -22,10 +21,12 @@ use function array_chunk;
 class TagRepositoryTest extends DatabaseTestCase
 {
     private TagRepository $repo;
+    private PersistenceShortUrlRelationResolver $relationResolver;
 
     protected function beforeEach(): void
     {
         $this->repo = $this->getEntityManager()->getRepository(Tag::class);
+        $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
     }
 
     /** @test */
@@ -52,49 +53,44 @@ class TagRepositoryTest extends DatabaseTestCase
     public function properTagsInfoIsReturned(): void
     {
         $names = ['foo', 'bar', 'baz', 'another'];
-        $tags = [];
         foreach ($names as $name) {
-            $tag = new Tag($name);
-            $tags[] = $tag;
-            $this->getEntityManager()->persist($tag);
+            $this->getEntityManager()->persist(new Tag($name));
         }
+        $this->getEntityManager()->flush();
 
-        [$firstUrlTags] = array_chunk($tags, 3);
-        $secondUrlTags = [$tags[0]];
+        [$firstUrlTags] = array_chunk($names, 3);
+        $secondUrlTags = [$names[0]];
+        $metaWithTags = fn (array $tags) => ShortUrlMeta::fromRawData(['longUrl' => '', 'tags' => $tags]);
 
-        $shortUrl = ShortUrl::createEmpty();
-        $shortUrl->setTags(new ArrayCollection($firstUrlTags));
+        $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags), $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl);
         $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
         $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
         $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
 
-        $shortUrl2 = ShortUrl::createEmpty();
-        $shortUrl2->setTags(new ArrayCollection($secondUrlTags));
+        $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags), $this->relationResolver);
         $this->getEntityManager()->persist($shortUrl2);
         $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance()));
-
         $this->getEntityManager()->flush();
 
         $result = $this->repo->findTagsWithInfo();
 
         self::assertCount(4, $result);
-        self::assertEquals(
-            ['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0],
-            $result[0]->jsonSerialize(),
-        );
-        self::assertEquals(
-            ['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3],
-            $result[1]->jsonSerialize(),
-        );
-        self::assertEquals(
-            ['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3],
-            $result[2]->jsonSerialize(),
-        );
-        self::assertEquals(
-            ['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4],
-            $result[3]->jsonSerialize(),
-        );
+        self::assertEquals(0, $result[0]->shortUrlsCount());
+        self::assertEquals(0, $result[0]->visitsCount());
+        self::assertEquals($names[3], $result[0]->tag()->__toString());
+
+        self::assertEquals(1, $result[1]->shortUrlsCount());
+        self::assertEquals(3, $result[1]->visitsCount());
+        self::assertEquals($names[1], $result[1]->tag()->__toString());
+
+        self::assertEquals(1, $result[2]->shortUrlsCount());
+        self::assertEquals(3, $result[2]->visitsCount());
+        self::assertEquals($names[2], $result[2]->tag()->__toString());
+
+        self::assertEquals(2, $result[3]->shortUrlsCount());
+        self::assertEquals(4, $result[3]->visitsCount());
+        self::assertEquals($names[0], $result[3]->tag()->__toString());
     }
 
     /** @test */
@@ -110,24 +106,23 @@ class TagRepositoryTest extends DatabaseTestCase
         $this->getEntityManager()->persist($domainApiKey);
 
         $names = ['foo', 'bar', 'baz', 'another'];
-        $tags = [];
         foreach ($names as $name) {
-            $tag = new Tag($name);
-            $tags[] = $tag;
-            $this->getEntityManager()->persist($tag);
+            $this->getEntityManager()->persist(new Tag($name));
         }
+        $this->getEntityManager()->flush();
 
-        [$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3);
+        [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3);
 
-        $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '']));
-        $shortUrl->setTags(new ArrayCollection($firstUrlTags));
+        $shortUrl = ShortUrl::fromMeta(
+            ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]),
+            $this->relationResolver,
+        );
         $this->getEntityManager()->persist($shortUrl);
 
         $shortUrl2 = ShortUrl::fromMeta(
-            ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'longUrl' => '']),
-            new PersistenceShortUrlRelationResolver($this->getEntityManager()),
+            ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags]),
+            $this->relationResolver,
         );
-        $shortUrl2->setTags(new ArrayCollection($secondUrlTags));
         $this->getEntityManager()->persist($shortUrl2);
 
         $this->getEntityManager()->flush();

+ 25 - 35
module/Core/test-db/Repository/VisitRepositoryTest.php

@@ -5,11 +5,9 @@ declare(strict_types=1);
 namespace ShlinkioTest\Shlink\Core\Repository;
 
 use Cake\Chronos\Chronos;
-use Doctrine\Common\Collections\ArrayCollection;
 use Shlinkio\Shlink\Common\Util\DateRange;
 use Shlinkio\Shlink\Core\Entity\Domain;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\Entity\Tag;
 use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\Entity\VisitLocation;
 use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@@ -28,10 +26,12 @@ use function sprintf;
 class VisitRepositoryTest extends DatabaseTestCase
 {
     private VisitRepository $repo;
+    private PersistenceShortUrlRelationResolver $relationResolver;
 
     protected function beforeEach(): void
     {
         $this->repo = $this->getEntityManager()->getRepository(Visit::class);
+        $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
     }
 
     /**
@@ -126,58 +126,45 @@ class VisitRepositoryTest extends DatabaseTestCase
     /** @test */
     public function findVisitsByTagReturnsProperData(): void
     {
-        $foo = new Tag('foo');
-        $this->getEntityManager()->persist($foo);
+        $foo = 'foo';
 
         /** @var ShortUrl $shortUrl */
-        [,, $shortUrl] = $this->createShortUrlsAndVisits(false);
-        /** @var ShortUrl $shortUrl2 */
-        [,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
-        /** @var ShortUrl $shortUrl3 */
-        [,, $shortUrl3] = $this->createShortUrlsAndVisits(false);
+        $this->createShortUrlsAndVisits(false, [$foo]);
+        $this->getEntityManager()->flush();
 
-        $shortUrl->setTags(new ArrayCollection([$foo]));
-        $shortUrl2->setTags(new ArrayCollection([$foo]));
-        $shortUrl3->setTags(new ArrayCollection([$foo]));
+        $this->createShortUrlsAndVisits(false, [$foo]);
+        $this->getEntityManager()->flush();
 
+        $this->createShortUrlsAndVisits(false, [$foo]);
         $this->getEntityManager()->flush();
 
         self::assertCount(0, $this->repo->findVisitsByTag('invalid'));
-        self::assertCount(18, $this->repo->findVisitsByTag((string) $foo));
-        self::assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
+        self::assertCount(18, $this->repo->findVisitsByTag($foo));
+        self::assertCount(6, $this->repo->findVisitsByTag($foo, new DateRange(
             Chronos::parse('2016-01-02'),
             Chronos::parse('2016-01-03'),
         )));
-        self::assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
-            Chronos::parse('2016-01-03'),
-        )));
+        self::assertCount(12, $this->repo->findVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03'))));
     }
 
     /** @test */
     public function countVisitsByTagReturnsProperData(): void
     {
-        $foo = new Tag('foo');
-        $this->getEntityManager()->persist($foo);
-
-        /** @var ShortUrl $shortUrl */
-        [,, $shortUrl] = $this->createShortUrlsAndVisits(false);
-        /** @var ShortUrl $shortUrl2 */
-        [,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
+        $foo = 'foo';
 
-        $shortUrl->setTags(new ArrayCollection([$foo]));
-        $shortUrl2->setTags(new ArrayCollection([$foo]));
+        $this->createShortUrlsAndVisits(false, [$foo]);
+        $this->getEntityManager()->flush();
 
+        $this->createShortUrlsAndVisits(false, [$foo]);
         $this->getEntityManager()->flush();
 
         self::assertEquals(0, $this->repo->countVisitsByTag('invalid'));
-        self::assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
-        self::assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
+        self::assertEquals(12, $this->repo->countVisitsByTag($foo));
+        self::assertEquals(4, $this->repo->countVisitsByTag($foo, new DateRange(
             Chronos::parse('2016-01-02'),
             Chronos::parse('2016-01-03'),
         )));
-        self::assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
-            Chronos::parse('2016-01-03'),
-        )));
+        self::assertEquals(8, $this->repo->countVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03'))));
     }
 
     /** @test */
@@ -192,7 +179,7 @@ class VisitRepositoryTest extends DatabaseTestCase
         $this->getEntityManager()->persist($apiKey1);
         $shortUrl = ShortUrl::fromMeta(
             ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']),
-            new PersistenceShortUrlRelationResolver($this->getEntityManager()),
+            $this->relationResolver,
         );
         $this->getEntityManager()->persist($shortUrl);
         $this->createVisitsForShortUrl($shortUrl, 4);
@@ -205,7 +192,7 @@ class VisitRepositoryTest extends DatabaseTestCase
 
         $shortUrl3 = ShortUrl::fromMeta(
             ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']),
-            new PersistenceShortUrlRelationResolver($this->getEntityManager()),
+            $this->relationResolver,
         );
         $this->getEntityManager()->persist($shortUrl3);
         $this->createVisitsForShortUrl($shortUrl3, 7);
@@ -221,9 +208,12 @@ class VisitRepositoryTest extends DatabaseTestCase
         self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
     }
 
-    private function createShortUrlsAndVisits(bool $withDomain = true): array
+    private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array
     {
-        $shortUrl = ShortUrl::createEmpty();
+        $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+            'longUrl' => '',
+            'tags' => $tags,
+        ]), $this->relationResolver);
         $domain = 'example.com';
         $shortCode = $shortUrl->getShortCode();
         $this->getEntityManager()->persist($shortUrl);

+ 4 - 25
module/Core/test/Service/ShortUrlServiceTest.php

@@ -6,19 +6,18 @@ namespace ShlinkioTest\Shlink\Core\Service;
 
 use Cake\Chronos\Chronos;
 use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\EntityRepository;
 use PHPUnit\Framework\TestCase;
 use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\Entity\Tag;
 use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
 use Shlinkio\Shlink\Core\Service\ShortUrlService;
+use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
 use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
 use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
@@ -48,6 +47,7 @@ class ShortUrlServiceTest extends TestCase
             $this->em->reveal(),
             $this->urlResolver->reveal(),
             $this->urlValidator->reveal(),
+            new SimpleShortUrlRelationResolver(),
         );
     }
 
@@ -75,32 +75,11 @@ class ShortUrlServiceTest extends TestCase
         self::assertCount(4, $paginator->getCurrentPageResults());
     }
 
-    /**
-     * @test
-     * @dataProvider provideAdminApiKeys
-     */
-    public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void
-    {
-        $shortUrl = $this->prophesize(ShortUrl::class);
-        $shortUrl->setTags(Argument::any())->shouldBeCalledOnce();
-        $shortCode = 'abc123';
-        $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)
-            ->willReturn($shortUrl->reveal())
-            ->shouldBeCalledOnce();
-
-        $tagRepo = $this->prophesize(EntityRepository::class);
-        $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce();
-        $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce();
-        $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
-
-        $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey);
-    }
-
     /**
      * @test
      * @dataProvider provideShortUrlEdits
      */
-    public function updateMetadataByShortCodeUpdatesProvidedData(
+    public function updateShortUrlUpdatesProvidedData(
         int $expectedValidateCalls,
         ShortUrlEdit $shortUrlEdit,
         ?ApiKey $apiKey
@@ -114,7 +93,7 @@ class ShortUrlServiceTest extends TestCase
         )->willReturn($shortUrl);
         $flush = $this->em->flush()->willReturn(null);
 
-        $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey);
+        $result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey);
 
         self::assertSame($shortUrl, $result);
         self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince());

+ 3 - 4
module/Core/test/Service/UrlShortenerTest.php

@@ -5,14 +5,12 @@ declare(strict_types=1);
 namespace ShlinkioTest\Shlink\Core\Service;
 
 use Cake\Chronos\Chronos;
-use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\TestCase;
 use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\Entity\Tag;
 use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
 use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@@ -119,7 +117,7 @@ class UrlShortenerTest extends TestCase
         ), ShortUrl::withLongUrl($url)];
         yield [
             ShortUrlMeta::fromRawData(['findIfExists' => true, 'longUrl' => $url, 'tags' => ['foo', 'bar']]),
-            ShortUrl::withLongUrl($url)->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])),
+            ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => $url, 'tags' => ['foo', 'bar']])),
         ];
         yield [
             ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]),
@@ -157,7 +155,8 @@ class UrlShortenerTest extends TestCase
                 'validUntil' => Chronos::parse('2017-01-01'),
                 'maxVisits' => 4,
                 'longUrl' => $url,
-            ]))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])),
+                'tags' => ['foo', 'bar', 'baz'],
+            ])),
         ];
     }
 }

+ 41 - 0
module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php

@@ -7,9 +7,12 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver;
 use Doctrine\ORM\EntityManagerInterface;
 use Doctrine\Persistence\ObjectRepository;
 use PHPUnit\Framework\TestCase;
+use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Entity\Tag;
+use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
 
 class PersistenceShortUrlRelationResolverTest extends TestCase
@@ -62,4 +65,42 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
         yield 'not found domain' => [null, $authority];
         yield 'found domain' => [new Domain($authority), $authority];
     }
+
+    /** @test */
+    public function findsAndPersistsTagsWrappedIntoCollection(): void
+    {
+        $tags = ['foo', 'bar', 'baz'];
+
+        $tagRepo = $this->prophesize(TagRepositoryInterface::class);
+        $findTag = $tagRepo->findOneBy(Argument::type('array'))->will(function (array $args): ?Tag {
+            ['name' => $name] = $args[0];
+            return $name === 'foo' ? new Tag($name) : null;
+        });
+        $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
+        $persist = $this->em->persist(Argument::type(Tag::class));
+
+        $result = $this->resolver->resolveTags($tags);
+
+        self::assertCount(3, $result);
+        self::assertEquals([new Tag('foo'), new Tag('bar'), new Tag('baz')], $result->toArray());
+        $findTag->shouldHaveBeenCalledTimes(3);
+        $getRepo->shouldHaveBeenCalledOnce();
+        $persist->shouldHaveBeenCalledTimes(3);
+    }
+
+    /** @test */
+    public function returnsEmptyCollectionWhenProvidingEmptyListOfTags(): void
+    {
+        $tagRepo = $this->prophesize(TagRepositoryInterface::class);
+        $findTag = $tagRepo->findOneBy(Argument::type('array'))->willReturn(null);
+        $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
+        $persist = $this->em->persist(Argument::type(Tag::class));
+
+        $result = $this->resolver->resolveTags([]);
+
+        self::assertEmpty($result);
+        $findTag->shouldNotHaveBeenCalled();
+        $getRepo->shouldNotHaveBeenCalled();
+        $persist->shouldNotHaveBeenCalled();
+    }
 }

+ 12 - 0
module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver;
 
 use PHPUnit\Framework\TestCase;
 use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Entity\Tag;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
 
 class SimpleShortUrlRelationResolverTest extends TestCase
@@ -38,4 +39,15 @@ class SimpleShortUrlRelationResolverTest extends TestCase
         yield 'empty domain' => [null];
         yield 'non-empty domain' => ['domain.com'];
     }
+
+    /** @test */
+    public function tagsAreWrappedInEntityCollection(): void
+    {
+        $tags = ['foo', 'bar', 'baz'];
+
+        $result = $this->resolver->resolveTags($tags);
+
+        self::assertCount(3, $result);
+        self::assertEquals([new Tag('foo'), new Tag('bar'), new Tag('baz')], $result->toArray());
+    }
 }

+ 1 - 1
module/Rest/config/dependencies.config.php

@@ -59,7 +59,7 @@ return [
             Service\UrlShortener::class,
             'config.url_shortener.domain',
         ],
-        Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class],
+        Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
         Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class],
         Action\ShortUrl\ResolveShortUrlAction::class => [
             Service\ShortUrl\ShortUrlResolver::class,

+ 3 - 5
module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php

@@ -16,22 +16,20 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
 abstract class AbstractCreateShortUrlAction extends AbstractRestAction
 {
     private UrlShortenerInterface $urlShortener;
-    private array $domainConfig;
+    private ShortUrlDataTransformer $transformer;
 
     public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
     {
         $this->urlShortener = $urlShortener;
-        $this->domainConfig = $domainConfig;
+        $this->transformer = new ShortUrlDataTransformer($domainConfig);
     }
 
     public function handle(Request $request): Response
     {
         $shortUrlMeta = $this->buildShortUrlData($request);
-
         $shortUrl = $this->urlShortener->shorten($shortUrlMeta);
-        $transformer = new ShortUrlDataTransformer($this->domainConfig);
 
-        return new JsonResponse($transformer->transform($shortUrl));
+        return new JsonResponse($this->transformer->transform($shortUrl));
     }
 
     /**

+ 8 - 4
module/Rest/src/Action/ShortUrl/EditShortUrlAction.php

@@ -4,12 +4,13 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
 
-use Laminas\Diactoros\Response\EmptyResponse;
+use Laminas\Diactoros\Response\JsonResponse;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
+use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
 use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
 use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
 
@@ -19,10 +20,12 @@ class EditShortUrlAction extends AbstractRestAction
     protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT];
 
     private ShortUrlServiceInterface $shortUrlService;
+    private ShortUrlDataTransformer $transformer;
 
-    public function __construct(ShortUrlServiceInterface $shortUrlService)
+    public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
     {
         $this->shortUrlService = $shortUrlService;
+        $this->transformer = new ShortUrlDataTransformer($domainConfig);
     }
 
     public function handle(ServerRequestInterface $request): ResponseInterface
@@ -31,7 +34,8 @@ class EditShortUrlAction extends AbstractRestAction
         $identifier = ShortUrlIdentifier::fromApiRequest($request);
         $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
 
-        $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit, $apiKey);
-        return new EmptyResponse();
+        $shortUrl = $this->shortUrlService->updateShortUrl($identifier, $shortUrlEdit, $apiKey);
+
+        return new JsonResponse($this->transformer->transform($shortUrl));
     }
 }

+ 6 - 1
module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php

@@ -8,11 +8,14 @@ use Laminas\Diactoros\Response\JsonResponse;
 use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
 use Shlinkio\Shlink\Core\Exception\ValidationException;
+use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
+use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
 use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
 use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
 
+/** @deprecated */
 class EditShortUrlTagsAction extends AbstractRestAction
 {
     protected const ROUTE_PATH = '/short-urls/{shortCode}/tags';
@@ -38,7 +41,9 @@ class EditShortUrlTagsAction extends AbstractRestAction
         $identifier = ShortUrlIdentifier::fromApiRequest($request);
         $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
 
-        $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags, $apiKey);
+        $shortUrl = $this->shortUrlService->updateShortUrl($identifier, ShortUrlEdit::fromRawData([
+            ShortUrlInputFilter::TAGS => $tags,
+        ]), $apiKey);
         return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
     }
 }

+ 3 - 5
module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php

@@ -22,12 +22,12 @@ class ListShortUrlsAction extends AbstractRestAction
     protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
 
     private ShortUrlServiceInterface $shortUrlService;
-    private array $domainConfig;
+    private ShortUrlDataTransformer $transformer;
 
     public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
     {
         $this->shortUrlService = $shortUrlService;
-        $this->domainConfig = $domainConfig;
+        $this->transformer = new ShortUrlDataTransformer($domainConfig);
     }
 
     public function handle(Request $request): Response
@@ -36,8 +36,6 @@ class ListShortUrlsAction extends AbstractRestAction
             ShortUrlsParams::fromRawData($request->getQueryParams()),
             AuthenticationMiddleware::apiKeyFromRequest($request),
         );
-        return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
-            $this->domainConfig,
-        ))]);
+        return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, $this->transformer)]);
     }
 }

+ 3 - 4
module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php

@@ -19,22 +19,21 @@ class ResolveShortUrlAction extends AbstractRestAction
     protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
 
     private ShortUrlResolverInterface $urlResolver;
-    private array $domainConfig;
+    private ShortUrlDataTransformer $transformer;
 
     public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig)
     {
         $this->urlResolver = $urlResolver;
-        $this->domainConfig = $domainConfig;
+        $this->transformer = new ShortUrlDataTransformer($domainConfig);
     }
 
     public function handle(Request $request): Response
     {
-        $transformer = new ShortUrlDataTransformer($this->domainConfig);
         $url = $this->urlResolver->resolveShortUrl(
             ShortUrlIdentifier::fromApiRequest($request),
             AuthenticationMiddleware::apiKeyFromRequest($request),
         );
 
-        return new JsonResponse($transformer->transform($url));
+        return new JsonResponse($this->transformer->transform($url));
     }
 }

+ 14 - 4
module/Rest/test-api/Action/CreateShortUrlTest.php

@@ -60,13 +60,23 @@ class CreateShortUrlTest extends ApiTestCase
         }
     }
 
-    /** @test */
-    public function createsNewShortUrlWithTags(): void
+    /**
+     * @test
+     * @dataProvider provideTags
+     */
+    public function createsNewShortUrlWithTags(array $providedTags, array $expectedTags): void
     {
-        [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => ['foo', 'bar', 'baz']]);
+        [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => $providedTags]);
 
         self::assertEquals(self::STATUS_OK, $statusCode);
-        self::assertEquals(['foo', 'bar', 'baz'], $tags);
+        self::assertEquals($expectedTags, $tags);
+    }
+
+    public function provideTags(): iterable
+    {
+        yield 'simple tags' => [$simpleTags = ['foo', 'bar', 'baz'], $simpleTags];
+        yield 'tags with spaces' => [['fo o', '  bar', 'b az'], ['fo-o', 'bar', 'b-az']];
+        yield 'tags with special chars' => [['UUU', 'Aäa'], ['uuu', 'aäa']];
     }
 
     /**

+ 19 - 0
module/Rest/test-api/Action/EditShortUrlTagsTest.php

@@ -52,6 +52,25 @@ class EditShortUrlTagsTest extends ApiTestCase
         self::assertEquals($domain, $payload['domain'] ?? null);
     }
 
+    /** @test */
+    public function allowsEditingTagsWithTwoEndpoints(): void
+    {
+        $getUrlTagsFromApi = fn () => $this->getJsonResponsePayload(
+            $this->callApiWithKey(self::METHOD_GET, '/short-urls/abc123'),
+        )['tags'] ?? null;
+        self::assertEquals(['foo'], $getUrlTagsFromApi());
+
+        $this->callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => [
+            'tags' => ['a', 'e'],
+        ]]);
+        self::assertEquals(['a', 'e'], $getUrlTagsFromApi());
+
+        $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/abc123', [RequestOptions::JSON => [
+            'tags' => ['i', 'o', 'u'],
+        ]]);
+        self::assertEquals(['i', 'o', 'u'], $getUrlTagsFromApi());
+    }
+
     /** @test */
     public function tagsAreSetOnProperShortUrlBasedOnProvidedDomain(): void
     {

+ 4 - 4
module/Rest/test-api/Action/EditShortUrlTest.php

@@ -41,8 +41,8 @@ class EditShortUrlTest extends ApiTestCase
         ]);
         $metaAfterResetting = $this->findShortUrlMetaByShortCode($shortCode);
 
-        self::assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode());
-        self::assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode());
+        self::assertEquals(self::STATUS_OK, $editWithProvidedMeta->getStatusCode());
+        self::assertEquals(self::STATUS_OK, $editWithResetMeta->getStatusCode());
         self::assertEquals($resetMeta, $metaAfterResetting);
         self::assertArraySubset($meta, $metaAfterEditing);
     }
@@ -93,7 +93,7 @@ class EditShortUrlTest extends ApiTestCase
 
     public function provideLongUrls(): iterable
     {
-        yield 'valid URL' => ['https://shlink.io', self::STATUS_NO_CONTENT, null];
+        yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null];
         yield 'invalid URL' => ['htt:foo', self::STATUS_BAD_REQUEST, 'INVALID_URL'];
     }
 
@@ -155,7 +155,7 @@ class EditShortUrlTest extends ApiTestCase
         ]]);
         $editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url));
 
-        self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
+        self::assertEquals(self::STATUS_OK, $editResp->getStatusCode());
         self::assertEquals($domain, $editedShortUrl['domain']);
         self::assertEquals($expectedUrl, $editedShortUrl['longUrl']);
         self::assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null);

+ 1 - 1
module/Rest/test-api/Action/ResolveShortUrlTest.php

@@ -29,7 +29,7 @@ class ResolveShortUrlTest extends ApiTestCase
         $visitResp = $this->callShortUrl($shortCode);
         $fetchResp = $this->callApiWithKey(self::METHOD_GET, $url);
 
-        self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
+        self::assertEquals(self::STATUS_OK, $editResp->getStatusCode());
         self::assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode());
         self::assertEquals(self::STATUS_OK, $fetchResp->getStatusCode());
     }

+ 13 - 6
module/Rest/test-api/Fixtures/ShortUrlsFixture.php

@@ -18,18 +18,23 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
 {
     public function getDependencies(): array
     {
-        return [ApiKeyFixture::class];
+        return [ApiKeyFixture::class, TagsFixture::class];
     }
 
     public function load(ObjectManager $manager): void
     {
+        $relationResolver = new PersistenceShortUrlRelationResolver($manager);
+
         /** @var ApiKey $authorApiKey */
         $authorApiKey = $this->getReference('author_api_key');
 
         $abcShortUrl = $this->setShortUrlDate(
-            ShortUrl::fromMeta(ShortUrlMeta::fromRawData(
-                ['customSlug' => 'abc123', 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io'],
-            )),
+            ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+                'customSlug' => 'abc123',
+                'apiKey' => $authorApiKey,
+                'longUrl' => 'https://shlink.io',
+                'tags' => ['foo'],
+            ]), $relationResolver),
             '2018-05-01',
         );
         $manager->persist($abcShortUrl);
@@ -40,7 +45,8 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
             'apiKey' => $authorApiKey,
             'longUrl' =>
                 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
-        ])), '2019-01-01 00:00:10');
+            'tags' => ['foo', 'bar'],
+        ]), $relationResolver), '2019-01-01 00:00:10');
         $manager->persist($defShortUrl);
 
         $customShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData(
@@ -61,7 +67,8 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
             'customSlug' => 'ghi789',
             'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-'
                 . 'source-software-projects/',
-        ]), new PersistenceShortUrlRelationResolver($manager)), '2019-01-01 00:00:30');
+            'tags' => ['foo'],
+        ]), $relationResolver), '2019-01-01 00:00:30');
         $manager->persist($withDomainDuplicatingShortCode);
 
         $withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlMeta::fromRawData(

+ 3 - 25
module/Rest/test-api/Fixtures/TagsFixture.php

@@ -4,40 +4,18 @@ declare(strict_types=1);
 
 namespace ShlinkioApiTest\Shlink\Rest\Fixtures;
 
-use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\DataFixtures\AbstractFixture;
-use Doctrine\Common\DataFixtures\DependentFixtureInterface;
 use Doctrine\Persistence\ObjectManager;
-use Shlinkio\Shlink\Core\Entity\ShortUrl;
 use Shlinkio\Shlink\Core\Entity\Tag;
 
-class TagsFixture extends AbstractFixture implements DependentFixtureInterface
+class TagsFixture extends AbstractFixture
 {
-    public function getDependencies(): array
-    {
-        return [ShortUrlsFixture::class];
-    }
-
     public function load(ObjectManager $manager): void
     {
-        $fooTag = new Tag('foo');
-        $manager->persist($fooTag);
-        $barTag = new Tag('bar');
-        $manager->persist($barTag);
+        $manager->persist(new Tag('foo'));
+        $manager->persist(new Tag('bar'));
         $manager->persist(new Tag('baz'));
 
-        /** @var ShortUrl $abcShortUrl */
-        $abcShortUrl = $this->getReference('abc123_short_url');
-        $abcShortUrl->setTags(new ArrayCollection([$fooTag]));
-
-        /** @var ShortUrl $defShortUrl */
-        $defShortUrl = $this->getReference('def456_short_url');
-        $defShortUrl->setTags(new ArrayCollection([$fooTag, $barTag]));
-
-        /** @var ShortUrl $exampleShortUrl */
-        $exampleShortUrl = $this->getReference('example_short_url');
-        $exampleShortUrl->setTags(new ArrayCollection([$fooTag]));
-
         $manager->flush();
     }
 }

+ 3 - 3
module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php

@@ -25,7 +25,7 @@ class EditShortUrlActionTest extends TestCase
     public function setUp(): void
     {
         $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
-        $this->action = new EditShortUrlAction($this->shortUrlService->reveal());
+        $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), []);
     }
 
     /** @test */
@@ -48,13 +48,13 @@ class EditShortUrlActionTest extends TestCase
                                         ->withParsedBody([
                                             'maxVisits' => 5,
                                         ]);
-        $updateMeta = $this->shortUrlService->updateMetadataByShortCode(Argument::cetera())->willReturn(
+        $updateMeta = $this->shortUrlService->updateShortUrl(Argument::cetera())->willReturn(
             ShortUrl::createEmpty(),
         );
 
         $resp = $this->action->handle($request);
 
-        self::assertEquals(204, $resp->getStatusCode());
+        self::assertEquals(200, $resp->getStatusCode());
         $updateMeta->shouldHaveBeenCalled();
     }
 }

+ 3 - 2
module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php

@@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy;
 use Psr\Http\Message\ServerRequestInterface;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
 use Shlinkio\Shlink\Core\Exception\ValidationException;
+use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Service\ShortUrlService;
 use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction;
@@ -41,9 +42,9 @@ class EditShortUrlTagsActionTest extends TestCase
     public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void
     {
         $shortCode = 'abc123';
-        $this->shortUrlService->setTagsByShortCode(
+        $this->shortUrlService->updateShortUrl(
             new ShortUrlIdentifier($shortCode),
-            [],
+            Argument::type(ShortUrlEdit::class),
             Argument::type(ApiKey::class),
         )->willReturn(ShortUrl::createEmpty())
          ->shouldBeCalledOnce();