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

Feature/improve url relations
This commit is contained in:
Alejandro Celaya 2021-01-31 16:26:50 +01:00 committed by GitHub
commit e30c9c86ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 372 additions and 259 deletions

View File

@ -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*

View File

@ -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.",

View File

@ -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"

View File

@ -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'],

View File

@ -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());
}
}
/**

View File

@ -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;
}

View File

@ -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;

View File

@ -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();

View File

@ -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

View File

@ -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);

View File

@ -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;
}));
}
}

View File

@ -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;
}

View File

@ -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)));
}
}

View File

@ -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)));
}
}

View File

@ -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();
}
},
);
}

View File

@ -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);
$shortUrl2 = ShortUrl::fromMeta($meta);
$shortUrl2->setTags($tagEntities);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::fromMeta($meta);
$shortUrl3->setTags($tagEntities);
$this->getEntityManager()->persist($shortUrl3);
$this->getEntityManager()->flush();
$result = $this->repo->findOneMatching($metaWithTags);
$shortUrl2 = ShortUrl::fromMeta($meta, $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$this->getEntityManager()->flush();
$shortUrl3 = ShortUrl::fromMeta($meta, $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3);
$this->getEntityManager()->flush();
$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();

View File

@ -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();

View File

@ -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);
$foo = 'foo';
/** @var ShortUrl $shortUrl */
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
/** @var ShortUrl $shortUrl2 */
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
$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);

View File

@ -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());

View File

@ -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'],
])),
];
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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,

View File

@ -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));
}
/**

View File

@ -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));
}
}

View File

@ -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()]);
}
}

View File

@ -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)]);
}
}

View File

@ -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));
}
}

View File

@ -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']];
}
/**

View File

@ -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
{

View File

@ -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);

View File

@ -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());
}

View File

@ -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(

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();