Completed
Push — master ( ede4af...ce4315 )
by Chauncey
02:23
created

CacheMiddleware::disableCacheHeadersOnResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 6
rs 10
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
     * @param array $data Constructor dependencies and options.
109
     */
110
    public function __construct(array $data)
111
    {
112
        $data = array_replace($this->defaults(), $data);
113
114
        $this->cachePool = $data['cache'];
115
        $this->cacheTtl  = $data['ttl'];
116
117
        $this->methods       = (array)$data['methods'];
118
        $this->statusCodes   = (array)$data['status_codes'];
119
120
        $this->includedPath  = $data['included_path'];
121
        $this->excludedPath  = $data['excluded_path'];
122
123
        $this->includedQuery = $data['included_query'];
124
        $this->excludedQuery = $data['excluded_query'];
125
        $this->ignoredQuery  = $data['ignored_query'];
126
    }
127
128
    /**
129
     * Default middleware options.
130
     *
131
     * @return array
132
     */
133
    public function defaults()
134
    {
135
        return [
136
            'ttl'            => CacheConfig::DAY_IN_SECONDS,
137
138
            'included_path'  => '*',
139
            'excluded_path'  => [ '^/admin\b' ],
140
141
            'methods'        => [ 'GET' ],
142
            'status_codes'   => [ 200 ],
143
144
            'included_query' => null,
145
            'excluded_query' => null,
146
            'ignored_query'  => null
147
        ];
148
    }
149
150
    /**
151
     * Load a route content from path's cache.
152
     *
153
     * This method is as dumb / simple as possible.
154
     * It does not rely on any sort of settings / configuration.
155
     * Simply: if the cache for the route exists, it will be used to display the page.
156
     * The `$next` callback will not be called, therefore stopping the middleware stack.
157
     *
158
     * To generate the cache used in this middleware,
159
     * @see \Charcoal\App\Middleware\CacheGeneratorMiddleware.
160
     *
161
     * @param  RequestInterface  $request  The PSR-7 HTTP request.
162
     * @param  ResponseInterface $response The PSR-7 HTTP response.
163
     * @param  callable          $next     The next middleware callable in the stack.
164
     * @return ResponseInterface
165
     */
166
    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next)
167
    {
168
        // Bail early
169
        if (!$this->isRequestMethodValid($request)) {
170
            return $next($request, $response);
171
        }
172
173
        $cacheKey  = $this->cacheKeyFromRequest($request);
174
        $cacheItem = $this->cachePool->getItem($cacheKey);
175
176
        if ($cacheItem->isHit()) {
177
            $cached = $cacheItem->get();
178
            $response->getBody()->write($cached['body']);
179
            foreach ($cached['headers'] as $name => $header) {
180
                $response = $response->withHeader($name, $header);
181
            }
182
183
            return $response;
184
        }
185
186
        $uri   = $request->getUri();
187
        $path  = $uri->getPath();
188
        $query = [];
189
190
        parse_str($uri->getQuery(), $query);
191
192
        $response = $next($request, $response);
193
194
        if (!$this->isResponseStatusValid($response)) {
195
            return $this->disableCacheHeadersOnResponse($response);
196
        }
197
198
        if (!$this->isPathIncluded($path)) {
199
            return $this->disableCacheHeadersOnResponse($response);
200
        }
201
202
        if ($this->isPathExcluded($path)) {
203
            return $this->disableCacheHeadersOnResponse($response);
204
        }
205
206
        if (!$this->isQueryIncluded($query)) {
207
            $queryArr = $this->parseIgnoredParams($query);
208
            if (!empty($queryArr)) {
209
                return $this->disableCacheHeadersOnResponse($response);
210
            }
211
        }
212
213
        if ($this->isQueryExcluded($query)) {
214
            return $this->disableCacheHeadersOnResponse($response);
215
        }
216
217
        // Nothing has excluded the cache so far: add it to the pool.
218
        $cacheItem->expiresAfter($this->cacheTtl);
219
        $cacheItem->set([
220
            'body'    => (string)$response->getBody(),
221
            'headers' => (array)$response->getHeaders(),
222
        ]);
223
        $this->cachePool->save($cacheItem);
224
225
        return $response;
226
    }
227
228
    /**
229
     * Generate the cache key from the HTTP request.
230
     *
231
     * @param  RequestInterface $request The PSR-7 HTTP request.
232
     * @return string
233
     */
234
    private function cacheKeyFromRequest(RequestInterface $request)
235
    {
236
        $uri = $request->getUri();
237
238
        $queryStr = $uri->getQuery();
239
        if (!empty($queryStr)) {
240
            $queryArr = [];
241
242
            parse_str($queryStr, $queryArr);
243
244
            $queryArr = $this->parseIgnoredParams($queryArr);
245
            $queryStr = http_build_query($queryArr);
246
247
            $uri = $uri->withQuery($queryStr);
248
        }
249
250
        $cacheKey = 'request/' . $request->getMethod() . '/' . md5((string)$uri);
251
        return $cacheKey;
252
    }
253
254
    /**
255
     * Determine if the HTTP request method matches the accepted choices.
256
     *
257
     * @param  RequestInterface $request The PSR-7 HTTP request.
258
     * @return boolean
259
     */
260
    private function isRequestMethodValid(RequestInterface $request)
261
    {
262
        return in_array($request->getMethod(), $this->methods);
263
    }
264
265
    /**
266
     * Determine if the HTTP response status matches the accepted choices.
267
     *
268
     * @param  ResponseInterface $response The PSR-7 HTTP response.
269
     * @return boolean
270
     */
271
    private function isResponseStatusValid(ResponseInterface $response)
272
    {
273
        return in_array($response->getStatusCode(), $this->statusCodes);
274
    }
275
276
    /**
277
     * Determine if the request should be cached based on the URI path.
278
     *
279
     * @param  string $path The request path (route) to verify.
280
     * @return boolean
281
     */
282
    private function isPathIncluded($path)
283
    {
284
        if ($this->includedPath === '*') {
285
            return true;
286
        }
287
288
        if (empty($this->includedPath) && !is_numeric($this->includedPath)) {
289
            return false;
290
        }
291
292
        foreach ((array)$this->includedPath as $included) {
293
            if (preg_match('@' . $included . '@', $path)) {
294
                return true;
295
            }
296
        }
297
298
        return false;
299
    }
300
301
    /**
302
     * Determine if the request should NOT be cached based on the URI path.
303
     *
304
     * @param  string $path The request path (route) to verify.
305
     * @return boolean
306
     */
307
    private function isPathExcluded($path)
308
    {
309
        if ($this->excludedPath === '*') {
310
            return true;
311
        }
312
313
        if (empty($this->excludedPath) && !is_numeric($this->excludedPath)) {
314
            return false;
315
        }
316
317
        foreach ((array)$this->excludedPath as $excluded) {
318
            if (preg_match('@' . $excluded . '@', $path)) {
319
                return true;
320
            }
321
        }
322
323
        return false;
324
    }
325
326
    /**
327
     * Determine if the request should be cached based on the URI query.
328
     *
329
     * @param  array $queryParams The query parameters to verify.
330
     * @return boolean
331
     */
332
    private function isQueryIncluded(array $queryParams)
333
    {
334
        if (empty($queryParams)) {
335
            return true;
336
        }
337
338
        if ($this->includedQuery === '*') {
339
            return true;
340
        }
341
342
        if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
343
            return false;
344
        }
345
346
        $includedParams = array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
347
        return (count($includedParams) > 0);
348
    }
349
350
    /**
351
     * Determine if the request should NOT be cached based on the URI query.
352
     *
353
     * @param  array $queryParams The query parameters to verify.
354
     * @return boolean
355
     */
356
    private function isQueryExcluded(array $queryParams)
357
    {
358
        if (empty($queryParams)) {
359
            return false;
360
        }
361
362
        if ($this->excludedQuery === '*') {
363
            return true;
364
        }
365
366
        if (empty($this->excludedQuery) && !is_numeric($this->excludedQuery)) {
367
            return false;
368
        }
369
370
        $excludedParams = array_intersect_key($queryParams, array_flip((array)$this->excludedQuery));
371
        return (count($excludedParams) > 0);
372
    }
373
374
    /**
375
     * Returns the query parameters that are NOT ignored.
376
     *
377
     * @param  array $queryParams The query parameters to filter.
378
     * @return array
379
     */
380
    private function parseIgnoredParams(array $queryParams)
381
    {
382
        if (empty($queryParams)) {
383
            return $queryParams;
384
        }
385
386
        if ($this->ignoredQuery === '*') {
387
            if ($this->includedQuery === '*') {
388
                return $queryParams;
389
            }
390
391
            if (empty($this->includedQuery) && !is_numeric($this->includedQuery)) {
392
                return [];
393
            }
394
395
            return array_intersect_key($queryParams, array_flip((array)$this->includedQuery));
396
        }
397
398
        if (empty($this->ignoredQuery) && !is_numeric($this->ignoredQuery)) {
399
            return $queryParams;
400
        }
401
402
        return array_diff_key($queryParams, array_flip((array)$this->ignoredQuery));
403
    }
404
405
    /**
406
     * Disable the HTTP cache headers.
407
     *
408
     * - `Cache-Control` is the proper HTTP header.
409
     * - `Pragma` is for HTTP 1.0 support.
410
     * - `Expires` is an alternative that is also supported by 1.0 proxies.
411
     *
412
     * @param  ResponseInterface $response The PSR-7 HTTP response.
413
     * @return ResponseInterface The new HTTP response.
414
     */
415
    private function disableCacheHeadersOnResponse(ResponseInterface $response)
416
    {
417
        return $response
418
               ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
419
               ->withHeader('Pragma', 'no-cache')
420
               ->withHeader('Expires', '0');
421
    }
422
}
423