Passed
Push — master ( 93ddb6...cc0f25 )
by Joel
09:25 queued 04:11
created

CacheMiddleware::setProcessCacheKeyCallback()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

305
    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...
306
    {
307
        if (isset($this->skipCache['session_vars'])) {
308
            $skip = $this->skipCache['session_vars'];
309
310
            if (!empty($skip)) {
311
                if (!session_id()) {
312
                    session_cache_limiter(false);
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type null|string expected by parameter $value of session_cache_limiter(). ( Ignorable by Annotation )

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

312
                    session_cache_limiter(/** @scrutinizer ignore-type */ false);
Loading history...
313
                    session_start();
314
                }
315
316
                if (array_intersect_key($_SESSION, array_flip($skip))) {
317
                    return true;
318
                }
319
            }
320
        }
321
322
        return false;
323
    }
324
325
    /**
326
     * Determine if the HTTP response status matches the accepted choices.
327
     *
328
     * @param  ResponseInterface $response The PSR-7 HTTP response.
329
     * @return boolean
330
     */
331
    private function isResponseStatusValid(ResponseInterface $response)
332
    {
333
        return in_array($response->getStatusCode(), $this->statusCodes);
334
    }
335
336
    /**
337
     * Determine if the request should be cached based on the URI path.
338
     *
339
     * @param  string $path The request path (route) to verify.
340
     * @return boolean
341
     */
342
    private function isPathIncluded($path)
343
    {
344
        if ($this->includedPath === '*') {
345
            return true;
346
        }
347
348
        if (empty($this->includedPath) && !is_numeric($this->includedPath)) {
349
            return false;
350
        }
351
352
        foreach ((array)$this->includedPath as $included) {
353
            if (preg_match('@' . $included . '@', $path)) {
354
                return true;
355
            }
356
        }
357
358
        return false;
359
    }
360
361
    /**
362
     * Determine if the request should NOT be cached based on the URI path.
363
     *
364
     * @param  string $path The request path (route) to verify.
365
     * @return boolean
366
     */
367
    private function isPathExcluded($path)
368
    {
369
        if ($this->excludedPath === '*') {
370
            return true;
371
        }
372
373
        if (empty($this->excludedPath) && !is_numeric($this->excludedPath)) {
374
            return false;
375
        }
376
377
        foreach ((array)$this->excludedPath as $excluded) {
378
            if (preg_match('@' . $excluded . '@', $path)) {
379
                return true;
380
            }
381
        }
382
383
        return false;
384
    }
385
386
    /**
387
     * Determine if the request should be cached based on the URI query.
388
     *
389
     * @param  array $queryParams The query parameters to verify.
390
     * @return boolean
391
     */
392
    private function isQueryIncluded(array $queryParams)
393
    {
394
        if (empty($queryParams)) {
395
            return true;
396
        }
397
398
        if ($this->includedQuery === '*') {
399
            return true;
400
        }
401
402
        if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
403
            return false;
404
        }
405
406
        $includedParams = array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
407
        return (count($includedParams) > 0);
408
    }
409
410
    /**
411
     * Determine if the request should NOT be cached based on the URI query.
412
     *
413
     * @param  array $queryParams The query parameters to verify.
414
     * @return boolean
415
     */
416
    private function isQueryExcluded(array $queryParams)
417
    {
418
        if (empty($queryParams)) {
419
            return false;
420
        }
421
422
        if ($this->excludedQuery === '*') {
423
            return true;
424
        }
425
426
        if (empty($this->excludedQuery) && !is_numeric($this->excludedQuery)) {
427
            return false;
428
        }
429
430
        $excludedParams = array_intersect_key($queryParams, array_flip((array)$this->excludedQuery));
431
        return (count($excludedParams) > 0);
432
    }
433
434
    /**
435
     * Returns the query parameters that are NOT ignored.
436
     *
437
     * @param  array $queryParams The query parameters to filter.
438
     * @return array
439
     */
440
    private function parseIgnoredParams(array $queryParams)
441
    {
442
        if (empty($queryParams)) {
443
            return $queryParams;
444
        }
445
446
        if ($this->ignoredQuery === '*') {
447
            if ($this->includedQuery === '*') {
448
                return $queryParams;
449
            }
450
451
            if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
452
                return [];
453
            }
454
455
            return array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
456
        }
457
458
        if (empty($this->ignoredQuery) && !is_numeric($this->ignoredQuery)) {
459
            return $queryParams;
460
        }
461
462
        return array_diff_key($queryParams, array_flip((array)$this->ignoredQuery));
463
    }
464
465
    /**
466
     * Disable the HTTP cache headers.
467
     *
468
     * - `Cache-Control` is the proper HTTP header.
469
     * - `Pragma` is for HTTP 1.0 support.
470
     * - `Expires` is an alternative that is also supported by 1.0 proxies.
471
     *
472
     * @param  ResponseInterface $response The PSR-7 HTTP response.
473
     * @return ResponseInterface The new HTTP response.
474
     */
475
    private function disableCacheHeadersOnResponse(ResponseInterface $response)
476
    {
477
        return $response
478
                ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
479
                ->withHeader('Pragma', 'no-cache')
480
                ->withHeader('Expires', '0');
481
    }
482
483
    /**
484
     * @param Closure|null $processCacheKeyCallback ProcessCacheKeyCallback for CacheMiddleware.
485
     * @return self
486
     */
487
    public function setProcessCacheKeyCallback($processCacheKeyCallback)
488
    {
489
        $this->processCacheKeyCallback = $processCacheKeyCallback;
490
491
        return $this;
492
    }
493
}
494