Completed
Push — master ( 30297a...987919 )
by Alejandro
13s
created

UrlShortener   A

Complexity

Total Complexity 18

Size/Duplication

Total Lines 189
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 7

Test Coverage

Coverage 95.31%

Importance

Changes 0
Metric Value
dl 0
loc 189
ccs 61
cts 64
cp 0.9531
rs 10
c 0
b 0
f 0
wmc 18
lcom 1
cbo 7

6 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 13 3
B urlToShortCode() 0 54 5
A checkUrlExists() 0 10 2
A convertAutoincrementIdToShortCode() 0 14 2
A processCustomSlug() 0 15 3
A shortCodeToUrl() 0 18 3
1
<?php
2
declare(strict_types=1);
3
4
namespace Shlinkio\Shlink\Core\Service;
5
6
use Cocur\Slugify\Slugify;
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\Repository\ShortUrlRepository;
19
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
20
21
class UrlShortener implements UrlShortenerInterface
22
{
23
    use TagManagerTrait;
24
25
    public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
26
27
    /**
28
     * @var ClientInterface
29
     */
30
    private $httpClient;
31
    /**
32
     * @var EntityManagerInterface
33
     */
34
    private $em;
35
    /**
36
     * @var string
37
     */
38
    private $chars;
39
    /**
40
     * @var SlugifyInterface
41
     */
42
    private $slugger;
43
    /**
44
     * @var bool
45
     */
46
    private $urlValidationEnabled;
47
48 8
    public function __construct(
49
        ClientInterface $httpClient,
50
        EntityManagerInterface $em,
51
        $urlValidationEnabled,
52
        $chars = self::DEFAULT_CHARS,
53
        SlugifyInterface $slugger = null
54
    ) {
55 8
        $this->httpClient = $httpClient;
56 8
        $this->em = $em;
57 8
        $this->urlValidationEnabled = $urlValidationEnabled;
58 8
        $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
59 8
        $this->slugger = $slugger ?: new Slugify();
0 ignored issues
show
Documentation Bug introduced by
It seems like $slugger ?: new \Cocur\Slugify\Slugify() can also be of type object<Cocur\Slugify\Slugify>. However, the property $slugger is declared as type object<Cocur\Slugify\SlugifyInterface>. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
60 8
    }
61
62
    /**
63
     * Creates and persists a unique shortcode generated for provided url
64
     *
65
     * @param UriInterface $url
66
     * @param string[] $tags
67
     * @param \DateTime|null $validSince
68
     * @param \DateTime|null $validUntil
69
     * @param string|null $customSlug
70
     * @param int|null $maxVisits
71
     * @return string
72
     * @throws NonUniqueSlugException
73
     * @throws InvalidUrlException
74
     * @throws RuntimeException
75
     */
76 6
    public function urlToShortCode(
77
        UriInterface $url,
78
        array $tags = [],
79
        \DateTime $validSince = null,
80
        \DateTime $validUntil = null,
81
        string $customSlug = null,
82
        int $maxVisits = null
83
    ): string {
84
        // If the url already exists in the database, just return its short code
85
        /** @var ShortUrl|null $shortUrl */
86 6
        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
87 6
            'originalUrl' => $url,
88
        ]);
89 6
        if ($shortUrl !== null) {
90 1
            return $shortUrl->getShortCode();
91
        }
92
93
        // Check if the validation of url is enabled in the config
94 5
        if (true === $this->urlValidationEnabled) {
95
            // Check that the URL exists
96 1
            $this->checkUrlExists($url);
97
        }
98 4
        $customSlug = $this->processCustomSlug($customSlug);
99
100
        // Transactionally insert the short url, then generate the short code and finally update the short code
101
        try {
102 3
            $this->em->beginTransaction();
103
104
            // First, create the short URL with an empty short code
105 3
            $shortUrl = new ShortUrl();
106 3
            $shortUrl->setOriginalUrl((string) $url)
0 ignored issues
show
Deprecated Code introduced by
The method Shlinkio\Shlink\Core\Ent...rtUrl::setOriginalUrl() has been deprecated with message: Use setLongUrl() instead

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

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

Loading history...
107 3
                     ->setValidSince($validSince)
108 3
                     ->setValidUntil($validUntil)
109 3
                     ->setMaxVisits($maxVisits);
110 3
            $this->em->persist($shortUrl);
111 3
            $this->em->flush();
112
113
            // Generate the short code and persist it
114 2
            $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
115 2
            $shortUrl->setShortCode($shortCode)
116 2
                     ->setTags($this->tagNamesToEntities($this->em, $tags));
117 2
            $this->em->flush();
118
119 2
            $this->em->commit();
120 2
            return $shortCode;
121 1
        } catch (\Throwable $e) {
122 1
            if ($this->em->getConnection()->isTransactionActive()) {
123 1
                $this->em->rollback();
124 1
                $this->em->close();
125
            }
126
127 1
            throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
128
        }
129
    }
130
131
    /**
132
     * Tries to perform a GET request to provided url, returning true on success and false on failure
133
     *
134
     * @param UriInterface $url
135
     * @return void
136
     */
137 1
    private function checkUrlExists(UriInterface $url)
138
    {
139
        try {
140 1
            $this->httpClient->request('GET', $url, ['allow_redirects' => [
141
                'max' => 15,
142
            ]]);
143 1
        } catch (GuzzleException $e) {
0 ignored issues
show
Bug introduced by
The class GuzzleHttp\Exception\GuzzleException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
144 1
            throw InvalidUrlException::fromUrl($url, $e);
145
        }
146
    }
147
148
    /**
149
     * Generates the unique shortcode for an autoincrement ID
150
     *
151
     * @param float $id
152
     * @return string
153
     */
154 1
    private function convertAutoincrementIdToShortCode(float $id): string
155
    {
156 1
        $id += 200000; // Increment the Id so that the generated shortcode is not too short
157 1
        $length = \strlen($this->chars);
158 1
        $code = '';
159
160 1
        while ($id > 0) {
161
            // Determine the value of the next higher character in the short code and prepend it
162 1
            $code = $this->chars[(int) fmod($id, $length)] . $code;
163 1
            $id = floor($id / $length);
164
        }
165
166 1
        return $this->chars[(int) $id] . $code;
167
    }
168
169 4
    private function processCustomSlug($customSlug)
170
    {
171 4
        if ($customSlug === null) {
172 2
            return null;
173
        }
174
175
        // If a custom slug was provided, check it is unique
176 2
        $customSlug = $this->slugger->slugify($customSlug);
177 2
        $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
178 2
        if ($shortUrl !== null) {
179 1
            throw NonUniqueSlugException::fromSlug($customSlug);
180
        }
181
182 1
        return $customSlug;
183
    }
184
185
    /**
186
     * Tries to find the mapped URL for provided short code. Returns null if not found
187
     *
188
     * @throws InvalidShortCodeException
189
     * @throws EntityDoesNotExistException
190
     */
191 2
    public function shortCodeToUrl(string $shortCode): ShortUrl
192
    {
193
        // Validate short code format
194 2
        if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) {
195 1
            throw InvalidShortCodeException::fromCharset($shortCode, $this->chars);
196
        }
197
198
        /** @var ShortUrlRepository $shortUrlRepo */
199 1
        $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
200 1
        $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode);
201 1
        if ($shortUrl === null) {
202
            throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
203
                'shortCode' => $shortCode,
204
            ]);
205
        }
206
207 1
        return $shortUrl;
208
    }
209
}
210