Test Failed
Push — master ( 01bc69...7e1eb8 )
by
unknown
05:39
created

CacheMiddleware::isSkipCache()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 7
nc 4
nop 1
dl 0
loc 13
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
namespace Charcoal\Cache\Middleware;
4
5
// From PSR-6
6
use Psr\Cache\CacheItemPoolInterface;
7
8
// From PSR-7
9
use Psr\Http\Message\RequestInterface;
10
use Psr\Http\Message\ResponseInterface;
11
12
// From 'charcoal-cache'
13
use Charcoal\Cache\CacheConfig;
14
15
/**
16
 * Charcoal HTTP Cache Middleware
17
 *
18
 * Saves or loads the HTTP response from a {@link https://www.php-fig.org/psr/psr-6/ PSR-6 cache pool}.
19
 * It uses {@see https://packagist.org/packages/tedivm/stash Stash} as the caching library, so you
20
 * have plenty of driver choices.
21
 *
22
 * The middleware saves the response body and headers in a cache pool and returns.
23
 *
24
 * The middleware will attempt to load a cached HTTP response based on the HTTP request's route.
25
 * The route must matched the middleware's conditons for allowed methods, paths, and query parameters,
26
 * as well as the response's status code.
27
 *
28
 * If the cache is a hit, the response is immediately returned; meaning that any subsequent middleware
29
 * in the stack will be ignored.
30
 *
31
 * Ideally, this middleware should be the first the execute on the stack, in most cases
32
 * (with Slim, this means adding it last).
33
 */
34
class CacheMiddleware
35
{
36
    /**
37
     * PSR-6 cache item pool.
38
     *
39
     * @var CacheItemPoolInterface
40
     */
41
    private $cachePool;
42
43
    /**
44
     * Cache response if the request matches one of the HTTP methods.
45
     *
46
     * @var string[]
47
     */
48
    private $methods;
49
50
    /**
51
     * Cache response if the request matches one of the HTTP status codes.
52
     *
53
     * @var integer[]
54
     */
55
    private $statusCodes;
56
57
    /**
58
     * Time-to-live in seconds.
59
     *
60
     * @var integer
61
     */
62
    private $cacheTtl;
63
64
    /**
65
     * Cache response if the request matches one of the URI path patterns.
66
     *
67
     * One or more regex patterns (excluding the outer delimiters).
68
     *
69
     * @var null|string|array
70
     */
71
    private $includedPath;
72
73
    /**
74
     * Cache response if the request does not match any of the URI path patterns.
75
     *
76
     * One or more regex patterns (excluding the outer delimiters).
77
     *
78
     * @var null|string|array
79
     */
80
    private $excludedPath;
81
82
    /**
83
     * Cache response if the request matches one of the query parameters.
84
     *
85
     * One or more query string fields.
86
     *
87
     * @var array|string|null
88
     */
89
    private $includedQuery;
90
91
    /**
92
     * Cache response if the request does not match any of the query parameters.
93
     *
94
     * One or more query string fields.
95
     *
96
     * @var array|string|null
97
     */
98
    private $excludedQuery;
99
100
    /**
101
     * Ignore query parameters from the request.
102
     *
103
     * @var array|string|null
104
     */
105
    private $ignoredQuery;
106
107
    /**
108
     * Skip cache early on various conditions.
109
     *
110
     * @var array|null
111
     */
112
    private $skipCache;
113
114
    /**
115
     * @param array $data Constructor dependencies and options.
116
     */
117
    public function __construct(array $data)
118
    {
119
        $data = array_replace($this->defaults(), $data);
120
121
        $this->cachePool = $data['cache'];
122
        $this->cacheTtl  = $data['ttl'];
123
124
        $this->methods       = (array)$data['methods'];
125
        $this->statusCodes   = (array)$data['status_codes'];
126
127
        $this->includedPath  = $data['included_path'];
128
        $this->excludedPath  = $data['excluded_path'];
129
130
        $this->includedQuery = $data['included_query'];
131
        $this->excludedQuery = $data['excluded_query'];
132
        $this->ignoredQuery  = $data['ignored_query'];
133
134
        $this->skipCache = (array)$data['skip_cache'];
135
    }
136
137
    /**
138
     * Default middleware options.
139
     *
140
     * @return array
141
     */
142
    public function defaults()
143
    {
144
        return [
145
            'ttl'            => CacheConfig::DAY_IN_SECONDS,
146
147
            'included_path'  => '*',
148
            'excluded_path'  => [ '^/admin\b' ],
149
150
            'methods'        => [ 'GET' ],
151
            'status_codes'   => [ 200 ],
152
153
            'included_query' => null,
154
            'excluded_query' => null,
155
            'ignored_query'  => null,
156
157
            'skip_cache' => [
158
                'session_vars' => []
159
            ]
160
        ];
161
    }
162
163
    /**
164
     * Load a route content from path's cache.
165
     *
166
     * This method is as dumb / simple as possible.
167
     * It does not rely on any sort of settings / configuration.
168
     * Simply: if the cache for the route exists, it will be used to display the page.
169
     * The `$next` callback will not be called, therefore stopping the middleware stack.
170
     *
171
     * To generate the cache used in this middleware,
172
     * @see \Charcoal\App\Middleware\CacheGeneratorMiddleware.
173
     *
174
     * @param  RequestInterface  $request  The PSR-7 HTTP request.
175
     * @param  ResponseInterface $response The PSR-7 HTTP response.
176
     * @param  callable          $next     The next middleware callable in the stack.
177
     * @return ResponseInterface
178
     */
179
    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next)
180
    {
181
        // Bail early
182
        if (!$this->isRequestMethodValid($request)) {
183
            return $next($request, $response);
184
        }
185
186
        if (!$this->isSkipCache($request)) {
187
            return $next($request, $response);
188
        }
189
    
190
        $cacheKey  = $this->cacheKeyFromRequest($request);
191
        $cacheItem = $this->cachePool->getItem($cacheKey);
192
193
        if ($cacheItem->isHit()) {
194
            $cached = $cacheItem->get();
195
            $response->getBody()->write($cached['body']);
196
            foreach ($cached['headers'] as $name => $header) {
197
                $response = $response->withHeader($name, $header);
198
            }
199
200
            return $response;
201
        }
202
203
        $uri   = $request->getUri();
204
        $path  = $uri->getPath();
205
        $query = [];
206
207
        parse_str($uri->getQuery(), $query);
208
209
        $response = $next($request, $response);
210
211
        if (!$this->isResponseStatusValid($response)) {
212
            return $this->disableCacheHeadersOnResponse($response);
213
        }
214
215
        if (!$this->isPathIncluded($path)) {
216
            return $this->disableCacheHeadersOnResponse($response);
217
        }
218
219
        if ($this->isPathExcluded($path)) {
220
            return $this->disableCacheHeadersOnResponse($response);
221
        }
222
223
        if (!$this->isQueryIncluded($query)) {
224
            $queryArr = $this->parseIgnoredParams($query);
225
            if (!empty($queryArr)) {
226
                return $this->disableCacheHeadersOnResponse($response);
227
            }
228
        }
229
230
        if ($this->isQueryExcluded($query)) {
231
            return $this->disableCacheHeadersOnResponse($response);
232
        }
233
234
        // Nothing has excluded the cache so far: add it to the pool.
235
        $cacheItem->expiresAfter($this->cacheTtl);
236
        $cacheItem->set([
237
            'body'    => (string)$response->getBody(),
238
            'headers' => (array)$response->getHeaders(),
239
        ]);
240
        $this->cachePool->save($cacheItem);
241
242
        return $response;
243
    }
244
245
    /**
246
     * Generate the cache key from the HTTP request.
247
     *
248
     * @param  RequestInterface $request The PSR-7 HTTP request.
249
     * @return string
250
     */
251
    private function cacheKeyFromRequest(RequestInterface $request)
252
    {
253
        $uri = $request->getUri();
254
255
        $queryStr = $uri->getQuery();
256
        if (!empty($queryStr)) {
257
            $queryArr = [];
258
259
            parse_str($queryStr, $queryArr);
260
261
            $queryArr = $this->parseIgnoredParams($queryArr);
262
            $queryStr = http_build_query($queryArr);
263
264
            $uri = $uri->withQuery($queryStr);
265
        }
266
267
        $cacheKey = 'request/' . $request->getMethod() . '/' . md5((string)$uri);
268
        return $cacheKey;
269
    }
270
271
    /**
272
     * Determine if the HTTP request method matches the accepted choices.
273
     *
274
     * @param  RequestInterface $request The PSR-7 HTTP request.
275
     * @return boolean
276
     */
277
    private function isRequestMethodValid(RequestInterface $request)
278
    {
279
        return in_array($request->getMethod(), $this->methods);
280
    }
281
282
    /**
283
     * Determine if the HTTP request method matches the accepted choices.
284
     *
285
     * @param  RequestInterface $request The PSR-7 HTTP request.
286
     * @return boolean
287
     */
288
    private function isSkipCache(RequestInterface $request)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

288
    private function isSkipCache(/** @scrutinizer ignore-unused */ RequestInterface $request)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
289
    {
290
        foreach ($this->skipCache as $ident => $skip) {
291
            switch ($ident) {
292
                case 'session_vars':
293
                    if (session_id() && in_array($_SESSION, $skip)) {
294
                        return true;
295
                    }
296
                    break;
297
            }
298
        }
299
300
        return false;
301
    }
302
303
    /**
304
     * Determine if the HTTP response status matches the accepted choices.
305
     *
306
     * @param  ResponseInterface $response The PSR-7 HTTP response.
307
     * @return boolean
308
     */
309
    private function isResponseStatusValid(ResponseInterface $response)
310
    {
311
        return in_array($response->getStatusCode(), $this->statusCodes);
312
    }
313
314
    /**
315
     * Determine if the request should be cached based on the URI path.
316
     *
317
     * @param  string $path The request path (route) to verify.
318
     * @return boolean
319
     */
320
    private function isPathIncluded($path)
321
    {
322
        if ($this->includedPath === '*') {
323
            return true;
324
        }
325
326
        if (empty($this->includedPath) && !is_numeric($this->includedPath)) {
327
            return false;
328
        }
329
330
        foreach ((array)$this->includedPath as $included) {
331
            if (preg_match('@' . $included . '@', $path)) {
332
                return true;
333
            }
334
        }
335
336
        return false;
337
    }
338
339
    /**
340
     * Determine if the request should NOT be cached based on the URI path.
341
     *
342
     * @param  string $path The request path (route) to verify.
343
     * @return boolean
344
     */
345
    private function isPathExcluded($path)
346
    {
347
        if ($this->excludedPath === '*') {
348
            return true;
349
        }
350
351
        if (empty($this->excludedPath) && !is_numeric($this->excludedPath)) {
352
            return false;
353
        }
354
355
        foreach ((array)$this->excludedPath as $excluded) {
356
            if (preg_match('@' . $excluded . '@', $path)) {
357
                return true;
358
            }
359
        }
360
361
        return false;
362
    }
363
364
    /**
365
     * Determine if the request should be cached based on the URI query.
366
     *
367
     * @param  array $queryParams The query parameters to verify.
368
     * @return boolean
369
     */
370
    private function isQueryIncluded(array $queryParams)
371
    {
372
        if (empty($queryParams)) {
373
            return true;
374
        }
375
376
        if ($this->includedQuery === '*') {
377
            return true;
378
        }
379
380
        if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
381
            return false;
382
        }
383
384
        $includedParams = array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
385
        return (count($includedParams) > 0);
386
    }
387
388
    /**
389
     * Determine if the request should NOT be cached based on the URI query.
390
     *
391
     * @param  array $queryParams The query parameters to verify.
392
     * @return boolean
393
     */
394
    private function isQueryExcluded(array $queryParams)
395
    {
396
        if (empty($queryParams)) {
397
            return false;
398
        }
399
400
        if ($this->excludedQuery === '*') {
401
            return true;
402
        }
403
404
        if (empty($this->excludedQuery) && !is_numeric($this->excludedQuery)) {
405
            return false;
406
        }
407
408
        $excludedParams = array_intersect_key($queryParams, array_flip((array)$this->excludedQuery));
409
        return (count($excludedParams) > 0);
410
    }
411
412
    /**
413
     * Returns the query parameters that are NOT ignored.
414
     *
415
     * @param  array $queryParams The query parameters to filter.
416
     * @return array
417
     */
418
    private function parseIgnoredParams(array $queryParams)
419
    {
420
        if (empty($queryParams)) {
421
            return $queryParams;
422
        }
423
424
        if ($this->ignoredQuery === '*') {
425
            if ($this->includedQuery === '*') {
426
                return $queryParams;
427
            }
428
429
            if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
430
                return [];
431
            }
432
433
            return array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
434
        }
435
436
        if (empty($this->ignoredQuery) && !is_numeric($this->ignoredQuery)) {
437
            return $queryParams;
438
        }
439
440
        return array_diff_key($queryParams, array_flip((array)$this->ignoredQuery));
441
    }
442
443
    /**
444
     * Disable the HTTP cache headers.
445
     *
446
     * - `Cache-Control` is the proper HTTP header.
447
     * - `Pragma` is for HTTP 1.0 support.
448
     * - `Expires` is an alternative that is also supported by 1.0 proxies.
449
     *
450
     * @param  ResponseInterface $response The PSR-7 HTTP response.
451
     * @return ResponseInterface The new HTTP response.
452
     */
453
    private function disableCacheHeadersOnResponse(ResponseInterface $response)
454
    {
455
        return $response
456
                ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
457
                ->withHeader('Pragma', 'no-cache')
458
                ->withHeader('Expires', '0');
459
    }
460
}
461