Completed
Push — master ( 781c6e...20c3bd )
by Alejandro
19s queued 10s
created

UrlShortener   A

Complexity

Total Complexity 31

Size/Duplication

Total Lines 184
Duplicated Lines 0 %

Test Coverage

Coverage 95.29%

Importance

Changes 0
Metric Value
wmc 31
eloc 85
dl 0
loc 184
ccs 81
cts 85
cp 0.9529
rs 9.92
c 0
b 0
f 0

7 Methods

Rating   Name   Duplication   Size   Complexity  
B urlToShortCode() 0 43 6
A __construct() 0 5 1
A verifyCustomSlug() 0 13 3
C findExistingShortUrlIfExists() 0 42 14
A checkUrlExists() 0 8 2
A convertAutoincrementIdToShortCode() 0 15 2
A shortCodeToUrl() 0 19 3
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
24
use function array_reduce;
25
use function count;
26
use function floor;
27
use function fmod;
28
use function Functional\contains;
29
use function Functional\invoke;
30
use function preg_match;
31
use function strlen;
32
33
class UrlShortener implements UrlShortenerInterface
34
{
35
    use TagManagerTrait;
36
37
    private const ID_INCREMENT = 200000;
38
39
    /** @var ClientInterface */
40
    private $httpClient;
41
    /** @var EntityManagerInterface */
42
    private $em;
43
    /** @var UrlShortenerOptions */
44
    private $options;
45
46 15
    public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options)
47
    {
48 15
        $this->httpClient = $httpClient;
49 15
        $this->em = $em;
50 15
        $this->options = $options;
51
    }
52
53
    /**
54
     * @param string[] $tags
55
     * @throws NonUniqueSlugException
56
     * @throws InvalidUrlException
57
     * @throws RuntimeException
58
     */
59 13
    public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
60
    {
61 13
        $url = (string) $url;
62
63
        // First, check if a short URL exists for all provided params
64 13
        $existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
65 13
        if ($existingShortUrl !== null) {
66 8
            return $existingShortUrl;
67
        }
68
69
        // If the URL validation is enabled, check that the URL actually exists
70 5
        if ($this->options->isUrlValidationEnabled()) {
71 1
            $this->checkUrlExists($url);
72
        }
73 4
        $this->verifyCustomSlug($meta);
74
75
        // Transactionally insert the short url, then generate the short code and finally update the short code
76
        try {
77 3
            $this->em->beginTransaction();
78
79
            // First, create the short URL with an empty short code
80 3
            $shortUrl = new ShortUrl($url, $meta);
81 3
            $this->em->persist($shortUrl);
82 3
            $this->em->flush();
83
84
            // Generate the short code and persist it if no custom slug was provided
85 2
            if (! $meta->hasCustomSlug()) {
86
                // TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
87 2
                $shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
88 2
                $shortUrl->setShortCode($shortCode);
89
            }
90 2
            $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
91 2
            $this->em->flush();
92
93 2
            $this->em->commit();
94 2
            return $shortUrl;
95 1
        } catch (Throwable $e) {
96 1
            if ($this->em->getConnection()->isTransactionActive()) {
97 1
                $this->em->rollback();
98 1
                $this->em->close();
99
            }
100
101 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
102
        }
103
    }
104
105 13
    private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
106
    {
107 13
        if (! $meta->findIfExists()) {
108 4
            return null;
109
        }
110
111 9
        $criteria = ['longUrl' => $url];
112 9
        if ($meta->hasCustomSlug()) {
113 1
            $criteria['shortCode'] = $meta->getCustomSlug();
114
        }
115
        /** @var ShortUrl[] $shortUrls */
116 9
        $shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria);
117 9
        if (empty($shortUrls)) {
118 1
            return null;
119
        }
120
121
        // Iterate short URLs until one that matches is found, or return null otherwise
122
        return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) {
123 8
            if ($found) {
124 1
                return $found;
125
            }
126
127 8
            if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $shortUrl->getMaxVisits()) {
128 1
                return null;
129
            }
130 8
            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

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