Completed
Push — master ( fcb912...17779d )
by Alejandro
06:59
created

UrlShortener::urlToShortCode()   A

Complexity

Conditions 4
Paths 38

Size

Total Lines 44

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 4

Importance

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