Completed
Pull Request — master (#246)
by Alejandro
05:59
created

UrlShortener::checkUrlExists()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 8
rs 10
c 0
b 0
f 0
ccs 4
cts 4
cp 1
crap 2
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\Repository\ShortUrlRepository;
20
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
21
use Throwable;
22
use function floor;
23
use function fmod;
24
use function preg_match;
25
use function strlen;
26
27
class UrlShortener implements UrlShortenerInterface
28
{
29
    use TagManagerTrait;
30
31
    public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
32
    private const ID_INCREMENT = 200000;
33
34
    /**
35
     * @var ClientInterface
36
     */
37
    private $httpClient;
38
    /**
39
     * @var EntityManagerInterface
40
     */
41
    private $em;
42
    /**
43
     * @var string
44
     */
45
    private $chars;
46
    /**
47
     * @var SlugifyInterface
48
     */
49
    private $slugger;
50
    /**
51
     * @var bool
52
     */
53
    private $urlValidationEnabled;
54
55 7
    public function __construct(
56
        ClientInterface $httpClient,
57
        EntityManagerInterface $em,
58
        $urlValidationEnabled,
59
        $chars = self::DEFAULT_CHARS,
60
        SlugifyInterface $slugger = null
61
    ) {
62 7
        $this->httpClient = $httpClient;
63 7
        $this->em = $em;
64 7
        $this->urlValidationEnabled = (bool) $urlValidationEnabled;
65 7
        $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
66 7
        $this->slugger = $slugger ?: new Slugify();
67 7
    }
68
69
    /**
70
     * @throws NonUniqueSlugException
71
     * @throws InvalidUrlException
72
     * @throws RuntimeException
73
     */
74 5
    public function urlToShortCode(
75
        UriInterface $url,
76
        array $tags = [],
77
        ?Chronos $validSince = null,
78
        ?Chronos $validUntil = null,
79
        ?string $customSlug = null,
80
        ?int $maxVisits = null
81
    ): ShortUrl {
82
        // If the URL validation is enabled, check that the URL actually exists
83 5
        if ($this->urlValidationEnabled) {
84 1
            $this->checkUrlExists($url);
85
        }
86 4
        $customSlug = $this->processCustomSlug($customSlug);
87
88
        // Transactionally insert the short url, then generate the short code and finally update the short code
89
        try {
90 3
            $this->em->beginTransaction();
91
92
            // First, create the short URL with an empty short code
93 3
            $shortUrl = new ShortUrl();
94 3
            $shortUrl->setOriginalUrl((string) $url)
0 ignored issues
show
Deprecated Code introduced by
The function Shlinkio\Shlink\Core\Ent...rtUrl::setOriginalUrl() has been deprecated: Use setLongUrl() instead ( Ignorable by Annotation )

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

94
            /** @scrutinizer ignore-deprecated */ $shortUrl->setOriginalUrl((string) $url)

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
95 3
                     ->setValidSince($validSince)
96 3
                     ->setValidUntil($validUntil)
97 3
                     ->setMaxVisits($maxVisits);
98 3
            $this->em->persist($shortUrl);
99 3
            $this->em->flush();
100
101
            // Generate the short code and persist it
102 2
            $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
103 2
            $shortUrl->setShortCode($shortCode)
104 2
                     ->setTags($this->tagNamesToEntities($this->em, $tags));
105 2
            $this->em->flush();
106
107 2
            $this->em->commit();
108 2
            return $shortUrl;
109 1
        } catch (Throwable $e) {
110 1
            if ($this->em->getConnection()->isTransactionActive()) {
111 1
                $this->em->rollback();
112 1
                $this->em->close();
113
            }
114
115 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
116
        }
117
    }
118
119
    /**
120
     * Tries to perform a GET request to provided url, returning true on success and false on failure
121
     *
122
     * @param UriInterface $url
123
     * @return void
124
     */
125 1
    private function checkUrlExists(UriInterface $url)
126
    {
127
        try {
128 1
            $this->httpClient->request('GET', $url, ['allow_redirects' => [
129
                'max' => 15,
130
            ]]);
131 1
        } catch (GuzzleException $e) {
132 1
            throw InvalidUrlException::fromUrl($url, $e);
133
        }
134
    }
135
136
    /**
137
     * Generates the unique shortcode for an autoincrement ID
138
     *
139
     * @param float $id
140
     * @return string
141
     */
142 1
    private function convertAutoincrementIdToShortCode(float $id): string
143
    {
144 1
        $id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
145 1
        $length = strlen($this->chars);
146 1
        $code = '';
147
148 1
        while ($id > 0) {
149
            // Determine the value of the next higher character in the short code and prepend it
150 1
            $code = $this->chars[(int) fmod($id, $length)] . $code;
151 1
            $id = floor($id / $length);
152
        }
153
154 1
        return $this->chars[(int) $id] . $code;
155
    }
156
157 4
    private function processCustomSlug($customSlug)
158
    {
159 4
        if ($customSlug === null) {
160 2
            return null;
161
        }
162
163
        // If a custom slug was provided, make sure it's unique
164 2
        $customSlug = $this->slugger->slugify($customSlug);
165 2
        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
166 2
        if ($shortUrl !== null) {
167 1
            throw NonUniqueSlugException::fromSlug($customSlug);
168
        }
169
170 1
        return $customSlug;
171
    }
172
173
    /**
174
     * Tries to find the mapped URL for provided short code. Returns null if not found
175
     *
176
     * @throws InvalidShortCodeException
177
     * @throws EntityDoesNotExistException
178
     */
179 2
    public function shortCodeToUrl(string $shortCode): ShortUrl
180
    {
181
        // Validate short code format
182 2
        if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) {
183 1
            throw InvalidShortCodeException::fromCharset($shortCode, $this->chars);
184
        }
185
186
        /** @var ShortUrlRepository $shortUrlRepo */
187 1
        $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
188 1
        $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode);
189 1
        if ($shortUrl === null) {
190
            throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
191
                'shortCode' => $shortCode,
192
            ]);
193
        }
194
195 1
        return $shortUrl;
196
    }
197
}
198