Passed
Pull Request — master (#500)
by Alejandro
06:23
created

UrlShortener::findExistingShortUrlIfExists()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 12
c 0
b 0
f 0
nc 5
nop 3
dl 0
loc 23
ccs 12
cts 12
cp 1
crap 6
rs 9.2222
1
<?php
2
declare(strict_types=1);
3
4
namespace Shlinkio\Shlink\Core\Service;
5
6
use Doctrine\ORM\EntityManagerInterface;
7
use Fig\Http\Message\RequestMethodInterface;
8
use GuzzleHttp\ClientInterface;
9
use GuzzleHttp\Exception\GuzzleException;
10
use GuzzleHttp\RequestOptions;
11
use Psr\Http\Message\UriInterface;
12
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
13
use Shlinkio\Shlink\Core\Entity\ShortUrl;
14
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
15
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
16
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
17
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
18
use Shlinkio\Shlink\Core\Exception\RuntimeException;
19
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
20
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
21
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
22
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
23
use Throwable;
24
25
use function array_reduce;
26
use function floor;
27
use function fmod;
28
use function preg_match;
29
use function strlen;
30
31
class UrlShortener implements UrlShortenerInterface
32
{
33
    use TagManagerTrait;
34
35
    private const ID_INCREMENT = 200000;
36
37
    /** @var ClientInterface */
38
    private $httpClient;
39
    /** @var EntityManagerInterface */
40
    private $em;
41
    /** @var UrlShortenerOptions */
42
    private $options;
43
44 15
    public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options)
45
    {
46 15
        $this->httpClient = $httpClient;
47 15
        $this->em = $em;
48 15
        $this->options = $options;
49
    }
50
51
    /**
52
     * @param string[] $tags
53
     * @throws NonUniqueSlugException
54
     * @throws InvalidUrlException
55
     * @throws RuntimeException
56
     */
57 13
    public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
58
    {
59 13
        $url = (string) $url;
60
61
        // First, check if a short URL exists for all provided params
62 13
        $existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
63 13
        if ($existingShortUrl !== null) {
64 8
            return $existingShortUrl;
65
        }
66
67
        // If the URL validation is enabled, check that the URL actually exists
68 5
        if ($this->options->isUrlValidationEnabled()) {
69 1
            $this->checkUrlExists($url);
70
        }
71 4
        $this->verifyCustomSlug($meta);
72
73
        // Transactionally insert the short url, then generate the short code and finally update the short code
74
        try {
75 3
            $this->em->beginTransaction();
76
77
            // First, create the short URL with an empty short code
78 3
            $shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
79 3
            $this->em->persist($shortUrl);
80 3
            $this->em->flush();
81
82
            // Generate the short code and persist it if no custom slug was provided
83 2
            if (! $meta->hasCustomSlug()) {
84
                // TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
85 2
                $shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
86 2
                $shortUrl->setShortCode($shortCode);
87
            }
88 2
            $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
89 2
            $this->em->flush();
90
91 2
            $this->em->commit();
92 2
            return $shortUrl;
93 1
        } catch (Throwable $e) {
94 1
            if ($this->em->getConnection()->isTransactionActive()) {
95 1
                $this->em->rollback();
96 1
                $this->em->close();
97
            }
98
99 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
100
        }
101
    }
102
103 13
    private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
104
    {
105 13
        if (! $meta->findIfExists()) {
106 4
            return null;
107
        }
108
109 9
        $criteria = ['longUrl' => $url];
110 9
        if ($meta->hasCustomSlug()) {
111 1
            $criteria['shortCode'] = $meta->getCustomSlug();
112
        }
113
        /** @var ShortUrl[] $shortUrls */
114 9
        $shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria);
115 9
        if (empty($shortUrls)) {
116 1
            return null;
117
        }
118
119
        // Iterate short URLs until one that matches is found, or return null otherwise
120
        return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) {
121 8
            if ($found !== null) {
122 1
                return $found;
123
            }
124
125 8
            return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null;
126 8
        });
127
    }
128
129 1
    private function checkUrlExists(string $url): void
130
    {
131
        try {
132 1
            $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
133 1
                RequestOptions::ALLOW_REDIRECTS => ['max' => 15],
134
            ]);
135 1
        } catch (GuzzleException $e) {
136 1
            throw InvalidUrlException::fromUrl($url, $e);
137
        }
138
    }
139
140 4
    private function verifyCustomSlug(ShortUrlMeta $meta): void
141
    {
142 4
        if (! $meta->hasCustomSlug()) {
143 3
            return;
144
        }
145
146 1
        $customSlug = $meta->getCustomSlug();
147 1
        $domain = $meta->getDomain();
148
149
        /** @var ShortUrlRepository $repo */
150 1
        $repo = $this->em->getRepository(ShortUrl::class);
151 1
        $shortUrlsCount = $repo->slugIsInUse($customSlug, $domain);
152 1
        if ($shortUrlsCount > 0) {
153 1
            throw NonUniqueSlugException::fromSlug($customSlug, $domain);
154
        }
155
    }
156
157 2
    private function convertAutoincrementIdToShortCode(float $id): string
158
    {
159 2
        $id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
160 2
        $chars = $this->options->getChars();
161
162 2
        $length = strlen($chars);
163 2
        $code = '';
164
165 2
        while ($id > 0) {
166
            // Determine the value of the next higher character in the short code and prepend it
167 2
            $code = $chars[(int) fmod($id, $length)] . $code;
168 2
            $id = floor($id / $length);
169
        }
170
171 2
        return $chars[(int) $id] . $code;
172
    }
173
174
    /**
175
     * @throws InvalidShortCodeException
176
     * @throws EntityDoesNotExistException
177
     */
178 2
    public function shortCodeToUrl(string $shortCode): ShortUrl
179
    {
180 2
        $chars = $this->options->getChars();
181
182
        // Validate short code format
183 2
        if (! preg_match('|[' . $chars . ']+|', $shortCode)) {
184 1
            throw InvalidShortCodeException::fromCharset($shortCode, $chars);
185
        }
186
187
        /** @var ShortUrlRepository $shortUrlRepo */
188 1
        $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
189 1
        $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode);
190 1
        if ($shortUrl === null) {
191
            throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
192
                'shortCode' => $shortCode,
193
            ]);
194
        }
195
196 1
        return $shortUrl;
197
    }
198
}
199