Completed
Push — master ( aa413d...84f608 )
by Alejandro
21s queued 11s
created

UrlShortener::convertAutoincrementIdToShortCode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2

Importance

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