BrowscapUpdater   A
last analyzed

Complexity

Total Complexity 38

Size/Duplication

Total Lines 370
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 13

Test Coverage

Coverage 80.67%

Importance

Changes 0
Metric Value
wmc 38
lcom 1
cbo 13
dl 0
loc 370
ccs 96
cts 119
cp 0.8067
rs 9.36
c 0
b 0
f 0

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 2
A convertFile() 0 18 4
A convertString() 0 14 2
C fetch() 0 75 9
B update() 0 61 7
C checkUpdate() 0 72 10
A sanitizeContent() 0 8 1
A storeContent() 0 10 3
1
<?php
2
declare(strict_types = 1);
3
4
namespace BrowscapPHP;
5
6
use BrowscapPHP\Cache\BrowscapCache;
7
use BrowscapPHP\Exception\ErrorCachedVersionException;
8
use BrowscapPHP\Exception\ErrorReadingFileException;
9
use BrowscapPHP\Exception\FetcherException;
10
use BrowscapPHP\Exception\FileNameMissingException;
11
use BrowscapPHP\Exception\FileNotFoundException;
12
use BrowscapPHP\Exception\NoCachedVersionException;
13
use BrowscapPHP\Exception\NoNewVersionException;
14
use BrowscapPHP\Helper\Converter;
15
use BrowscapPHP\Helper\ConverterInterface;
16
use BrowscapPHP\Helper\Filesystem;
17
use BrowscapPHP\Helper\IniLoader;
18
use BrowscapPHP\Helper\IniLoaderInterface;
19
use GuzzleHttp\Client;
20
use GuzzleHttp\ClientInterface;
21
use Psr\Log\LoggerInterface;
22
use Psr\SimpleCache\CacheInterface;
23
use Psr\SimpleCache\InvalidArgumentException;
24
25
/**
26
 * Browscap.ini parsing class with caching and update capabilities
27
 */
28
final class BrowscapUpdater implements BrowscapUpdaterInterface
29
{
30
    public const DEFAULT_TIMEOUT = 5;
31
32
    /**
33
     * The cache instance
34
     *
35
     * @var \BrowscapPHP\Cache\BrowscapCacheInterface
36
     */
37
    private $cache;
38
39
    /**
40
     * @var \Psr\Log\LoggerInterface
41
     */
42
    private $logger;
43
44
    /**
45
     * @var \GuzzleHttp\ClientInterface
46
     */
47
    private $client;
48
49
    /**
50
     * Curl connect timeout in seconds
51
     *
52
     * @var int
53
     */
54
    private $connectTimeout;
55
56
    /**
57
     * Browscap constructor.
58
     *
59 13
     * @param \Psr\SimpleCache\CacheInterface $cache
60
     * @param LoggerInterface                 $logger
61
     * @param ClientInterface|null            $client
62
     * @param int                             $connectTimeout
63
     */
64
    public function __construct(
65 13
        CacheInterface $cache,
66 13
        LoggerInterface $logger,
67
        ?ClientInterface $client = null,
68 13
        int $connectTimeout = self::DEFAULT_TIMEOUT
69 13
    ) {
70
        $this->cache = new BrowscapCache($cache, $logger);
71
        $this->logger = $logger;
72 13
73 13
        if (null === $client) {
74 13
            $client = new Client();
75
        }
76
77
        $this->client = $client;
0 ignored issues
show
Documentation Bug introduced by
It seems like $client can also be of type object<GuzzleHttp\Client>. However, the property $client is declared as type object<GuzzleHttp\ClientInterface>. 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...
78
        $this->connectTimeout = $connectTimeout;
79
    }
80
81
    /**
82
     * reads and parses an ini file and writes the results into the cache
83 3
     *
84
     * @param string $iniFile
85 3
     *
86 1
     * @throws \BrowscapPHP\Exception\FileNameMissingException
87
     * @throws \BrowscapPHP\Exception\FileNotFoundException
88
     * @throws \BrowscapPHP\Exception\ErrorReadingFileException
89 2
     */
90 1
    public function convertFile(string $iniFile) : void
91
    {
92
        if (empty($iniFile)) {
93
            throw new FileNameMissingException('the file name can not be empty');
94 1
        }
95
96
        if (! is_readable($iniFile)) {
97
            throw new FileNotFoundException('it was not possible to read the local file ' . $iniFile);
98
        }
99 1
100 1
        $iniString = file_get_contents($iniFile);
101
102
        if (false === $iniString) {
103
            throw new ErrorReadingFileException('an error occured while converting the local file into the cache');
104
        }
105
106
        $this->convertString($iniString);
107 2
    }
108
109
    /**
110 2
     * reads and parses an ini string and writes the results into the cache
111
     *
112
     * @param string $iniString
113
     */
114
    public function convertString(string $iniString) : void
115
    {
116
        try {
117 2
            $cachedVersion = $this->cache->getItem('browscap.version', false, $success);
118
        } catch (InvalidArgumentException $e) {
0 ignored issues
show
Bug introduced by
The class Psr\SimpleCache\InvalidArgumentException 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...
119 2
            $this->logger->error(new \InvalidArgumentException('an error occured while reading the data version from the cache', 0, $e));
120 2
121
            return;
122
        }
123
124
        $converter = new Converter($this->logger, $this->cache);
125
126
        $this->storeContent($converter, $iniString, $cachedVersion);
127
    }
128
129
    /**
130
     * fetches a remote file and stores it into a local folder
131
     *
132 3
     * @param string $file The name of the file where to store the remote content
133
     * @param string $remoteFile The code for the remote file to load
134
     *
135 3
     * @throws \BrowscapPHP\Exception\FetcherException
136
     * @throws \BrowscapPHP\Helper\Exception
137
     * @throws \BrowscapPHP\Exception\ErrorCachedVersionException
138
     */
139 3
    public function fetch(string $file, string $remoteFile = IniLoaderInterface::PHP_INI) : void
140 2
    {
141
        try {
142
            $cachedVersion = $this->checkUpdate();
143 2
        } catch (NoNewVersionException $e) {
144
            return;
145 2
        } catch (NoCachedVersionException $e) {
146 2
            $cachedVersion = 0;
147
        }
148 2
149
        $this->logger->debug('started fetching remote file');
150
151 2
        $loader = new IniLoader();
152
        $loader->setRemoteFilename($remoteFile);
153 2
154
        $uri = $loader->getRemoteIniUrl();
155
156
        try {
157
            /** @var \Psr\Http\Message\ResponseInterface $response */
158
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
159
        } catch (\GuzzleHttp\Exception\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...
160
            throw new FetcherException(
161 2
                sprintf(
162
                    'an error occured while fetching remote data from URI %s',
163
                    $uri
164
                ),
165
                0,
166 2
                $e
167
            );
168
        }
169
170
        if (200 !== $response->getStatusCode()) {
171
            throw new FetcherException(
172 2
                sprintf(
173 2
                    'an error occured while fetching remote data from URI %s: StatusCode was %d',
174
                    $uri,
175 2
                    $response->getStatusCode()
176
                )
177 2
            );
178 2
        }
179
180 2
        try {
181 2
            $content = $response->getBody()->getContents();
182 2
        } catch (\Exception $e) {
183
            throw new FetcherException('an error occured while fetching remote data', 0, $e);
184
        }
185 2
186 2
        if (empty($content)) {
187
            $error = error_get_last();
188
189
            if (is_array($error)) {
190
                throw FetcherException::httpError($uri, $error['message']);
191
            }
192
193
            throw FetcherException::httpError(
194
                $uri,
195
                'an error occured while fetching remote data, but no error was raised'
196
            );
197
        }
198
199
        $this->logger->debug('finished fetching remote file');
200
        $this->logger->debug('started storing remote file into local file');
201 2
202
        $content = $this->sanitizeContent($content);
203 2
204
        $converter = new Converter($this->logger, $this->cache);
205
        $iniVersion = $converter->getIniVersion($content);
206 2
207
        if ($iniVersion > $cachedVersion) {
208
            $fs = new Filesystem();
209
            $fs->dumpFile($file, $content);
210 2
        }
211 2
212
        $this->logger->debug('finished storing remote file into local file');
213
    }
214 2
215 2
    /**
216
     * fetches a remote file, parses it and writes the result into the cache
217 2
     * if the local stored information are in the same version as the remote data no actions are
218
     * taken
219
     *
220 2
     * @param string $remoteFile The code for the remote file to load
221
     *
222 2
     * @throws \BrowscapPHP\Exception\FetcherException
223
     * @throws \BrowscapPHP\Helper\Exception
224
     * @throws \BrowscapPHP\Exception\ErrorCachedVersionException
225
     */
226
    public function update(string $remoteFile = IniLoaderInterface::PHP_INI) : void
227
    {
228
        $this->logger->debug('started fetching remote file');
229
230 2
        try {
231
            $cachedVersion = $this->checkUpdate();
232
        } catch (NoNewVersionException $e) {
233
            return;
234
        } catch (NoCachedVersionException $e) {
235 2
            $cachedVersion = 0;
236 1
        }
237
238 1
        $loader = new IniLoader();
239
        $loader->setRemoteFilename($remoteFile);
240
241 1
        $uri = $loader->getRemoteIniUrl();
242
243 1
        try {
244
            /** @var \Psr\Http\Message\ResponseInterface $response */
245 1
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
246 1
        } catch (\GuzzleHttp\Exception\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...
247
            throw new FetcherException(
248
                sprintf(
249
                    'an error occured while fetching remote data from URI %s',
250
                    $uri
251
                ),
252
                0,
253
                $e
254
            );
255
        }
256
257
        if (200 !== $response->getStatusCode()) {
258 9
            throw new FetcherException(
259
                sprintf(
260 9
                    'an error occured while fetching remote data from URI %s: StatusCode was %d',
261
                    $uri,
262
                    $response->getStatusCode()
263 9
                )
264
            );
265
        }
266
267
        try {
268 9
            $content = $response->getBody()->getContents();
269
        } catch (\Exception $e) {
270 5
            throw new FetcherException('an error occured while fetching remote data', 0, $e);
271
        }
272
273 4
        if (empty($content)) {
274
            $error = error_get_last();
275
276 4
            throw FetcherException::httpError($uri, $error['message'] ?? '');
277
        }
278 4
279 1
        $this->logger->debug('finished fetching remote file');
280 1
        $this->logger->debug('started updating cache from remote file');
281 1
282
        $converter = new Converter($this->logger, $this->cache);
283
        $this->storeContent($converter, $content, $cachedVersion);
284
285
        $this->logger->debug('finished updating cache from remote file');
286 3
    }
287 1
288 1
    /**
289 1
     * checks if an update on a remote location for the local file or the cache
290 1
     *
291 1
     * @throws \BrowscapPHP\Exception\FetcherException
292 1
     * @throws \BrowscapPHP\Exception\NoCachedVersionException
293
     * @throws \BrowscapPHP\Exception\ErrorCachedVersionException
294
     * @throws \BrowscapPHP\Exception\NoNewVersionException
295
     *
296 2
     * @return int|null The actual cached version if a newer version is available, null otherwise
297
     */
298
    public function checkUpdate() : ?int
299
    {
300
        $success = null;
301
302
        try {
303 2
            $cachedVersion = $this->cache->getItem('browscap.version', false, $success);
304
        } catch (InvalidArgumentException $e) {
0 ignored issues
show
Bug introduced by
The class Psr\SimpleCache\InvalidArgumentException 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...
305 1
            throw new ErrorCachedVersionException('an error occured while reading the data version from the cache', 0, $e);
306
        }
307 1
308
        if (! $cachedVersion) {
309
            // could not load version from cache
310 1
            throw new NoCachedVersionException('there is no cached version available, please update from remote');
311 1
        }
312
313
        $uri = (new IniLoader())->getRemoteVersionUrl();
314 1
315
        try {
316
            /** @var \Psr\Http\Message\ResponseInterface $response */
317 5
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
318
        } catch (\GuzzleHttp\Exception\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...
319
            throw new FetcherException(
320 5
                sprintf(
321
                    'an error occured while fetching version data from URI %s',
322
                    $uri
323 5
                ),
324
                0,
325
                $e
326
            );
327
        }
328
329
        if (200 !== $response->getStatusCode()) {
330
            throw new FetcherException(
331
                sprintf(
332
                    'an error occured while fetching version data from URI %s: StatusCode was %d',
333 3
                    $uri,
334
                    $response->getStatusCode()
335 3
                )
336 3
            );
337
        }
338 3
339 3
        try {
340 3
            $remoteVersion = $response->getBody()->getContents();
341
        } catch (\Throwable $e) {
342 3
            throw new FetcherException(
343
                sprintf(
344
                    'an error occured while fetching version data from URI %s: StatusCode was %d',
345
                    $uri,
346
                    $response->getStatusCode()
347
                ),
348
                0,
349
                $e
350
            );
351
        }
352
353
        if (! $remoteVersion) {
354
            // could not load remote version
355
            throw new FetcherException(
356
                'could not load version from remote location'
357
            );
358
        }
359
360
        if ($cachedVersion && $remoteVersion && $remoteVersion <= $cachedVersion) {
361
            throw new NoNewVersionException('there is no newer version available');
362
        }
363
364
        $this->logger->info(
365
            'a newer version is available, local version: ' . $cachedVersion . ', remote version: ' . $remoteVersion
366
        );
367
368
        return (int) $cachedVersion;
369
    }
370
371
    private function sanitizeContent(string $content) : string
372
    {
373
        // replace everything between opening and closing php and asp tags
374
        $content = preg_replace('/<[?%].*[?%]>/', '', $content);
375
376
        // replace opening and closing php and asp tags
377
        return str_replace(['<?', '<%', '?>', '%>'], '', (string) $content);
378
    }
379
380
    /**
381
     * reads and parses an ini string and writes the results into the cache
382
     *
383
     * @param \BrowscapPHP\Helper\ConverterInterface $converter
384
     * @param string                                 $content
385
     * @param int|null                               $cachedVersion
386
     */
387
    private function storeContent(ConverterInterface $converter, string $content, ?int $cachedVersion) : void
388
    {
389
        $iniString = $this->sanitizeContent($content);
390
        $iniVersion = $converter->getIniVersion($iniString);
391
392
        if (! $cachedVersion || $iniVersion > $cachedVersion) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cachedVersion of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
393
            $converter->storeVersion();
394
            $converter->convertString($iniString);
395
        }
396
    }
397
}
398