Completed
Push — master ( 95d4cd...d9d4c8 )
by Alejandro
14s queued 10s
created

UrlShortener::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 2
nop 5
dl 0
loc 12
rs 10
c 0
b 0
f 0
ccs 6
cts 6
cp 1
crap 3
1
<?php
2
declare(strict_types=1);
3
4
namespace Shlinkio\Shlink\Core\Service;
5
6
use Cake\Chronos\Chronos;
7
use Cocur\Slugify\Slugify;
8
use Cocur\Slugify\SlugifyInterface;
9
use Doctrine\ORM\EntityManagerInterface;
10
use GuzzleHttp\ClientInterface;
11
use GuzzleHttp\Exception\GuzzleException;
12
use Psr\Http\Message\UriInterface;
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\Repository\ShortUrlRepository;
21
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
22
use Throwable;
23
use function floor;
24
use function fmod;
25
use function preg_match;
26
use function strlen;
27
28
class UrlShortener implements UrlShortenerInterface
29
{
30
    use TagManagerTrait;
31
32
    public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
33
    private const ID_INCREMENT = 200000;
34
35
    /**
36
     * @var ClientInterface
37
     */
38
    private $httpClient;
39
    /**
40
     * @var EntityManagerInterface
41
     */
42
    private $em;
43
    /**
44
     * @var string
45
     */
46
    private $chars;
47
    /**
48
     * @var SlugifyInterface
49
     */
50
    private $slugger;
51
    /**
52
     * @var bool
53
     */
54
    private $urlValidationEnabled;
55
56 7
    public function __construct(
57
        ClientInterface $httpClient,
58
        EntityManagerInterface $em,
59
        $urlValidationEnabled,
60
        $chars = self::DEFAULT_CHARS,
61
        SlugifyInterface $slugger = null
62
    ) {
63 7
        $this->httpClient = $httpClient;
64 7
        $this->em = $em;
65 7
        $this->urlValidationEnabled = (bool) $urlValidationEnabled;
66 7
        $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
67 7
        $this->slugger = $slugger ?: new Slugify();
68
    }
69
70
    /**
71
     * @throws NonUniqueSlugException
72
     * @throws InvalidUrlException
73
     * @throws RuntimeException
74
     */
75 5
    public function urlToShortCode(
76
        UriInterface $url,
77
        array $tags = [],
78
        ?Chronos $validSince = null,
79
        ?Chronos $validUntil = null,
80
        ?string $customSlug = null,
81
        ?int $maxVisits = null
82
    ): ShortUrl {
83
        // If the URL validation is enabled, check that the URL actually exists
84 5
        if ($this->urlValidationEnabled) {
85 1
            $this->checkUrlExists($url);
86
        }
87 4
        $customSlug = $this->processCustomSlug($customSlug);
88
89
        // Transactionally insert the short url, then generate the short code and finally update the short code
90
        try {
91 3
            $this->em->beginTransaction();
92
93
            // First, create the short URL with an empty short code
94 3
            $shortUrl = new ShortUrl(
95 3
                (string) $url,
96 3
                ShortUrlMeta::createFromParams($validSince, $validUntil, null, $maxVisits)
97
            );
98 3
            $this->em->persist($shortUrl);
99 3
            $this->em->flush();
100
101
            // Generate the short code and persist it
102
            // TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
103 2
            $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
104 2
            $shortUrl->setShortCode($shortCode)
105 2
                     ->setTags($this->tagNamesToEntities($this->em, $tags));
106 2
            $this->em->flush();
107
108 2
            $this->em->commit();
109 2
            return $shortUrl;
110 1
        } catch (Throwable $e) {
111 1
            if ($this->em->getConnection()->isTransactionActive()) {
112 1
                $this->em->rollback();
113 1
                $this->em->close();
114
            }
115
116 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
117
        }
118
    }
119
120
    /**
121
     * Tries to perform a GET request to provided url, returning true on success and false on failure
122
     *
123
     * @param UriInterface $url
124
     * @return void
125
     */
126 1
    private function checkUrlExists(UriInterface $url)
127
    {
128
        try {
129 1
            $this->httpClient->request('GET', $url, ['allow_redirects' => [
130
                'max' => 15,
131
            ]]);
132 1
        } catch (GuzzleException $e) {
133 1
            throw InvalidUrlException::fromUrl($url, $e);
134
        }
135
    }
136
137
    /**
138
     * Generates the unique shortcode for an autoincrement ID
139
     *
140
     * @param float $id
141
     * @return string
142
     */
143 1
    private function convertAutoincrementIdToShortCode(float $id): string
144
    {
145 1
        $id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
146 1
        $length = strlen($this->chars);
147 1
        $code = '';
148
149 1
        while ($id > 0) {
150
            // Determine the value of the next higher character in the short code and prepend it
151 1
            $code = $this->chars[(int) fmod($id, $length)] . $code;
152 1
            $id = floor($id / $length);
153
        }
154
155 1
        return $this->chars[(int) $id] . $code;
156
    }
157
158 4
    private function processCustomSlug($customSlug)
159
    {
160 4
        if ($customSlug === null) {
161 2
            return null;
162
        }
163
164
        // If a custom slug was provided, make sure it's unique
165 2
        $customSlug = $this->slugger->slugify($customSlug);
166 2
        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
167 2
        if ($shortUrl !== null) {
168 1
            throw NonUniqueSlugException::fromSlug($customSlug);
169
        }
170
171 1
        return $customSlug;
172
    }
173
174
    /**
175
     * Tries to find the mapped URL for provided short code. Returns null if not found
176
     *
177
     * @throws InvalidShortCodeException
178
     * @throws EntityDoesNotExistException
179
     */
180 2
    public function shortCodeToUrl(string $shortCode): ShortUrl
181
    {
182
        // Validate short code format
183 2
        if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) {
184 1
            throw InvalidShortCodeException::fromCharset($shortCode, $this->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