Completed
Pull Request — master (#218)
by Thomas
32:04
created

BrowscapUpdater   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 13

Test Coverage

Coverage 86.21%

Importance

Changes 7
Bugs 1 Features 1
Metric Value
wmc 34
lcom 1
cbo 13
dl 0
loc 318
ccs 100
cts 116
cp 0.8621
rs 9.2
c 7
b 1
f 1

8 Methods

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