Passed
Pull Request — master (#343)
by Alejandro
05:42
created

UrlShortener::urlToShortCode()   B

Complexity

Conditions 6
Paths 57

Size

Total Lines 43
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 24
nc 57
nop 3
dl 0
loc 43
ccs 24
cts 24
cp 1
crap 6
rs 8.9137
c 0
b 0
f 0
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\Entity\ShortUrl;
13
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
14
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
15
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
16
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
17
use Shlinkio\Shlink\Core\Exception\RuntimeException;
18
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
19
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
20
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
21
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
22
use Throwable;
23
use function array_reduce;
24
use function count;
25
use function floor;
26
use function fmod;
27
use function Functional\contains;
28
use function Functional\invoke;
29
use function preg_match;
30
use function strlen;
31
32
class UrlShortener implements UrlShortenerInterface
33
{
34
    use TagManagerTrait;
35
36
    private const ID_INCREMENT = 200000;
37
38
    /** @var ClientInterface */
39
    private $httpClient;
40
    /** @var EntityManagerInterface */
41
    private $em;
42
    /** @var UrlShortenerOptions */
43
    private $options;
44
45 13
    public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options)
46
    {
47 13
        $this->httpClient = $httpClient;
48 13
        $this->em = $em;
49 13
        $this->options = $options;
50
    }
51
52
    /**
53
     * @param string[] $tags
54
     * @throws NonUniqueSlugException
55
     * @throws InvalidUrlException
56
     * @throws RuntimeException
57
     */
58 11
    public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
59
    {
60 11
        $url = (string) $url;
61
62
        // First, check if a short URL exists for all provided params
63 11
        $existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
64 11
        if ($existingShortUrl !== null) {
65 7
            return $existingShortUrl;
66
        }
67
68
        // If the URL validation is enabled, check that the URL actually exists
69 4
        if ($this->options->isUrlValidationEnabled()) {
70 1
            $this->checkUrlExists($url);
71
        }
72 3
        $this->verifyCustomSlug($meta);
73
74
        // Transactionally insert the short url, then generate the short code and finally update the short code
75
        try {
76 2
            $this->em->beginTransaction();
77
78
            // First, create the short URL with an empty short code
79 2
            $shortUrl = new ShortUrl($url, $meta);
80 2
            $this->em->persist($shortUrl);
81 2
            $this->em->flush();
82
83
            // Generate the short code and persist it if no custom slug was provided
84 1
            if (! $meta->hasCustomSlug()) {
85
                // TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
86 1
                $shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
87 1
                $shortUrl->setShortCode($shortCode);
88
            }
89 1
            $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
90 1
            $this->em->flush();
91
92 1
            $this->em->commit();
93 1
            return $shortUrl;
94 1
        } catch (Throwable $e) {
95 1
            if ($this->em->getConnection()->isTransactionActive()) {
96 1
                $this->em->rollback();
97 1
                $this->em->close();
98
            }
99
100 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
101
        }
102
    }
103
104 11
    private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
105
    {
106 11
        if (! $meta->findIfExists()) {
107 4
            return null;
108
        }
109
110 7
        $criteria = ['longUrl' => $url];
111 7
        if ($meta->hasCustomSlug()) {
112 1
            $criteria['shortCode'] = $meta->getCustomSlug();
113
        }
114
        /** @var ShortUrl|null $shortUrl */
115 7
        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy($criteria);
116 7
        if ($shortUrl === null) {
117
            return null;
118
        }
119
120 7
        if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $shortUrl->getMaxVisits()) {
121
            return null;
122
        }
123 7
        if ($meta->hasValidSince() && ! $meta->getValidSince()->eq($shortUrl->getValidSince())) {
0 ignored issues
show
Bug introduced by
It seems like $shortUrl->getValidSince() can also be of type null; however, parameter $dt of Cake\Chronos\Chronos::eq() does only seem to accept Cake\Chronos\ChronosInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

123
        if ($meta->hasValidSince() && ! $meta->getValidSince()->eq(/** @scrutinizer ignore-type */ $shortUrl->getValidSince())) {
Loading history...
124
            return null;
125
        }
126 7
        if ($meta->hasValidUntil() && ! $meta->getValidUntil()->eq($shortUrl->getValidUntil())) {
127
            return null;
128
        }
129
130 7
        $shortUrlTags = invoke($shortUrl->getTags(), '__toString');
131 7
        $hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
132 7
            $tags,
133
            function (bool $hasAllTags, string $tag) use ($shortUrlTags) {
134 2
                return $hasAllTags && contains($shortUrlTags, $tag);
135 7
            },
136 7
            true
137
        );
138
139 7
        return $hasAllTags ? $shortUrl : null;
140
    }
141
142 1
    private function checkUrlExists(string $url): void
143
    {
144
        try {
145 1
            $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
146 1
                RequestOptions::ALLOW_REDIRECTS => ['max' => 15],
147
            ]);
148 1
        } catch (GuzzleException $e) {
149 1
            throw InvalidUrlException::fromUrl($url, $e);
150
        }
151
    }
152
153 3
    private function verifyCustomSlug(ShortUrlMeta $meta): void
154
    {
155 3
        if (! $meta->hasCustomSlug()) {
156 2
            return;
157
        }
158
159 1
        $customSlug = $meta->getCustomSlug();
160
161
        /** @var ShortUrlRepository $repo */
162 1
        $repo = $this->em->getRepository(ShortUrl::class);
163 1
        $shortUrlsCount = $repo->count(['shortCode' => $customSlug]);
164 1
        if ($shortUrlsCount > 0) {
165 1
            throw NonUniqueSlugException::fromSlug($customSlug);
166
        }
167
    }
168
169 1
    private function convertAutoincrementIdToShortCode(float $id): string
170
    {
171 1
        $id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
172 1
        $chars = $this->options->getChars();
173
174 1
        $length = strlen($chars);
175 1
        $code = '';
176
177 1
        while ($id > 0) {
178
            // Determine the value of the next higher character in the short code and prepend it
179 1
            $code = $chars[(int) fmod($id, $length)] . $code;
180 1
            $id = floor($id / $length);
181
        }
182
183 1
        return $chars[(int) $id] . $code;
184
    }
185
186
    /**
187
     * @throws InvalidShortCodeException
188
     * @throws EntityDoesNotExistException
189
     */
190 2
    public function shortCodeToUrl(string $shortCode): ShortUrl
191
    {
192 2
        $chars = $this->options->getChars();
193
194
        // Validate short code format
195 2
        if (! preg_match('|[' . $chars . ']+|', $shortCode)) {
196 1
            throw InvalidShortCodeException::fromCharset($shortCode, $chars);
197
        }
198
199
        /** @var ShortUrlRepository $shortUrlRepo */
200 1
        $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
201 1
        $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode);
202 1
        if ($shortUrl === null) {
203
            throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
204
                'shortCode' => $shortCode,
205
            ]);
206
        }
207
208 1
        return $shortUrl;
209
    }
210
}
211