Completed
Pull Request — master (#5)
by Chauncey
07:26 queued 05:26
created

CacheMiddleware::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 12
nc 1
nop 1
1
<?php
2
3
namespace Charcoal\App\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-app'
13
use Charcoal\App\Config\CacheConfig;
14
15
/**
16
 * The cache loader middleware attempts to load cache from the request's path (route).
17
 *
18
 * It should be run as the first middleware of the stack, in most cases.
19
 * (With Slim, this means adding it last)
20
 *
21
 * There is absolutely no extra configuration or dependencies to this middleware.
22
 * If the cache key exists, then it will be injected in the response body and returned.
23
 *
24
 * Note that if the cache is hit, the response is directly returned; meaning the rest
25
 * of the middilewares in the stack will be ignored
26
 *
27
 * It is up to other means, such as the provided `CacheGeneratorMiddleware`, to set this cache entry.
28
 */
29
class CacheMiddleware
30
{
31
    /**
32
     * PSR-6 cache item pool.
33
     *
34
     * @var CacheItemPool
35
     */
36
    private $cachePool;
37
38
    /**
39
     * Time-to-live in seconds.
40
     *
41
     * @var integer
42
     */
43
    private $cacheTtl;
44
45
    /**
46
     * If set, only queries matching this path
47
     * @var null|string|array
48
     */
49
    private $includedPath;
50
51
    /**
52
     * Queries matching
53
     * @var null|string|array
54
     */
55
    private $excludedPath;
56
57
    /**
58
     * Only cache the request if it matches one of those methods.
59
     * @var string[]
60
     */
61
    private $methods;
62
63
    /**
64
     * Only cache the request if it matches one of those status codes.
65
     * @var int[]
66
     */
67
    private $statusCode;
0 ignored issues
show
Unused Code introduced by
The property $statusCode is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
68
69
    /**
70
     * @var array|string|null
71
     */
72
    private $includedQuery;
73
74
    /**
75
     * @var array|string|null
76
     */
77
    private $excludedQuery;
78
79
    /**
80
     * @var array|string|null
81
     */
82
    private $ignoredQuery;
83
84
    /**
85
     * @var boolean
86
     */
87
    private $headers;
88
89
    /**
90
     * @param array $data Constructor dependencies and options.
91
     */
92
    public function __construct(array $data)
93
    {
94
        $data = array_replace($this->defaults(), $data);
95
96
        $this->cachePool = $data['cache'];
97
        $this->cacheTtl  = $data['ttl'];
98
99
        $this->includedPath = $data['included_path'];
100
        $this->excludedPath = $data['excluded_path'];
101
102
        $this->methods     = $data['methods'];
103
        $this->statusCodes = $data['status_codes'];
0 ignored issues
show
Bug introduced by
The property statusCodes does not seem to exist. Did you mean statusCode?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
104
105
        $this->includedQuery = $data['included_query'];
106
        $this->excludedQuery = $data['excluded_query'];
107
        $this->ignoredQuery  = $data['ignored_query'];
108
109
        $this->headers = $data['headers'];
110
    }
111
112
    /**
113
     * Default middleware options.
114
     *
115
     * @return array
116
     */
117
    public function defaults()
118
    {
119
        return [
120
            'ttl'            => CacheConfig::DAY_IN_SECONDS,
121
122
            'included_path'  => '*',
123
            'excluded_path'  => [ '~^/admin\b~' ],
124
125
            'methods'        => [ 'GET' ],
126
            'status_codes'   => [ 200 ],
127
128
            'included_query' => null,
129
            'excluded_query' => null,
130
            'ignored_query'  => null,
131
132
            'headers'        => true
133
        ];
134
    }
135
136
    /**
137
     * Load a route content from path's cache.
138
     *
139
     * This method is as dumb / simple as possible.
140
     * It does not rely on any sort of settings / configuration.
141
     * Simply: if the cache for the route exists, it will be used to display the page.
142
     * The `$next` callback will not be called, therefore stopping the middleware stack.
143
     *
144
     * To generate the cache used in this middleware,
145
     * @see \Charcoal\App\Middleware\CacheGeneratorMiddleware.
146
     *
147
     * @param RequestInterface  $request  The PSR-7 HTTP request.
148
     * @param ResponseInterface $response The PSR-7 HTTP response.
149
     * @param callable          $next     The next middleware callable in the stack.
150
     * @return ResponseInterface
151
     */
152
    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next)
153
    {
154
        $path = $request->getUri()->getPath();
155
        $queryParams = $request->getQueryParams();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Http\Message\RequestInterface as the method getQueryParams() does only exist in the following implementations of said interface: GuzzleHttp\Psr7\ServerRequest, Slim\Http\Request.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
156
        $cacheKey  = $this->cacheKey($path, $queryParams);
157
158
        if ($this->cachePool->hasItem($cacheKey)) {
159
            $cacheItem = $this->cachePool->getItem($cacheKey);
160
            $cached = $cacheItem->get();
161
            $response->getBody()->write($cached['body']);
162
            foreach ($cached['headers'] as $name => $header) {
163
                $response = $response->withHeader($name, $header);
164
            }
165
166
            return $response;
167
        } else {
168
            $response = $next($request, $response);
169
170
            if ($this->isActive($request, $response) === false) {
171
                return $response;
172
            }
173
174
            if ($this->isPathIncluded($path) === false) {
175
                return $response;
176
            }
177
178
            if ($this->isPathExcluded($path) === true) {
179
                return $response;
180
            }
181
182
            if ($this->isQueryIncluded($queryParams) === false) {
183
                $keyParams = $this->parseIgnoredParams($queryParams);
184
                if (!empty($keyParams)) {
185
                    return $response;
186
                }
187
            }
188
189
            if ($this->isQueryExcluded($queryParams) === true) {
190
                return $response;
191
            }
192
193
            // Nothing has excluded the cache so far: add it to the pool.
194
            $cacheItem = $this->cachePool->getItem($cacheKey);
195
            $cacheItem->expiresAfter($this->cacheTtl);
196
            $cacheItem = $cacheItem->set([
197
                'body'    => (string)$response->getBody(),
198
                'headers' => (array)$response->getHeaders()
199
            ]);
200
            $this->cachePool->save($cacheItem);
201
202
            return $response;
203
        }
204
    }
205
206
    /**
207
     * @param string $path        The query path (route).
208
     * @param array  $queryParams The query parameters.
209
     * @return string
210
     */
211
    private function cacheKey($path, array $queryParams)
212
    {
213
        $cacheKey  = 'request/'.str_replace('/', '.', $path);
214
        if (!empty($queryParams)) {
215
            $keyParams = $this->parseIgnoredParams($queryParams);
216
            $cacheKey .= '.'.md5(json_encode($keyParams));
217
        }
218
219
        return $cacheKey;
220
    }
221
222
    /**
223
     * @param RequestInterface  $request  The PSR-7 HTTP request.
224
     * @param ResponseInterface $response The PSR-7 HTTP response.
225
     * @return boolean
226
     */
227
    private function isActive(RequestInterface $request, ResponseInterface $response)
228
    {
229
        if (!in_array($response->getStatusCode(), $this->statusCodes)) {
0 ignored issues
show
Bug introduced by
The property statusCodes does not seem to exist. Did you mean statusCode?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
230
            return false;
231
        }
232
233
        if (!in_array($request->getMethod(), $this->methods)) {
234
            return false;
235
        }
236
237
        return true;
238
    }
239
240
    /**
241
     * @param string $path The request path (route) to verify.
242
     * @return boolean
243
     */
244 View Code Duplication
    private function isPathIncluded($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
245
    {
246
        if ($this->includedPath === '*') {
247
            return true;
248
        }
249
250
        if (!$this->includedPath || empty($this->includedPath)) {
251
            return false;
252
        }
253
254
        if (is_string($this->includedPath)) {
255
            return !!(preg_match('@'.$this->includedPath.'@', $path));
256
        }
257
258
        if (is_array($this->includedPath)) {
259
            foreach ($this->includedPath as $included) {
260
                if (preg_match('@'.$included.'@', $path)) {
261
                    return true;
262
                }
263
            }
264
            return false;
265
        }
266
    }
267
268
    /**
269
     * @param string $path The request path (route) to verify.
270
     * @return boolean
271
     */
272 View Code Duplication
    private function isPathExcluded($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
273
    {
274
        if ($this->excludedPath === '*') {
275
            return true;
276
        }
277
278
        if (!$this->excludedPath || empty($this->excludedPath)) {
279
            return false;
280
        }
281
282
        if (is_string($this->excludedPath)) {
283
            return !!(preg_match('@'.$this->excludedPath.'@', $path));
284
        }
285
286
        if (is_array($this->excludedPath)) {
287
            foreach ($this->excludedPath as $excluded) {
288
                if (preg_match('@'.$excluded.'@', $path)) {
289
                    return true;
290
                }
291
            }
292
            return false;
293
        }
294
    }
295
296
    /**
297
     * @param array $queryParams The query parameters.
298
     * @return boolean
299
     */
300 View Code Duplication
    private function isQueryIncluded(array $queryParams)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
301
    {
302
        if (empty($queryParams)) {
303
            return true;
304
        }
305
306
        if ($this->includedQuery === '*') {
307
            return true;
308
        }
309
310
        if (!is_array($this->includedQuery) || empty($this->includedQuery)) {
311
            return false;
312
        }
313
314
        return (count(array_intersect_key($queryParams, $this->includedQuery)) > 0);
315
    }
316
317
    /**
318
     * @param array $queryParams The query parameters.
319
     * @return boolean
320
     */
321 View Code Duplication
    private function isQueryExcluded(array $queryParams)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
322
    {
323
        if ($this->excludedQuery === '*') {
324
            return true;
325
        }
326
327
        if (!is_array($this->excludedQuery) || empty($this->excludedQuery)) {
328
            return false;
329
        }
330
331
        if (count(array_intersect_key($queryParams, array_flip($this->excludedQuery))) > 0) {
332
            return true;
333
        }
334
    }
335
336
    /**
337
     * @param array $queryParams The query parameters.
338
     * @return array
339
     */
340
    private function parseIgnoredParams(array $queryParams)
341
    {
342
        if ($this->ignoredQuery === '*') {
343
            $ret = [];
344
            if (is_array($this->includedQuery)) {
345
                foreach ($queryParams as $k => $v) {
346
                    if (in_array($k, $this->includedQuery)) {
347
                        $ret[$k] = $v;
348
                    }
349
                }
350
            }
351
            return $ret;
352
        }
353
354
        if (!is_array($this->ignoredQuery) || empty($this->ignoredQuery)) {
355
            return $queryParams;
356
        }
357
358
        $ret = [];
359
        foreach ($queryParams as $k => $v) {
360
            if (!in_array($k, $this->ignoredQuery)) {
361
                $ret[$k] = $v;
362
            }
363
        }
364
365
        return $queryParams;
366
    }
367
}
368