Completed
Pull Request — master (#5)
by Mathieu
01:53
created

CacheMiddleware::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
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
97
        $this->cachePool = $data['cache'];
98
        $this->cacheTtl  = $data['ttl'];
99
100
        $this->includedPath = $data['included_path'];
101
        $this->excludedPath = $data['excluded_path'];
102
103
        $this->methods     = $data['methods'];
104
        $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...
105
106
        $this->includedQuery = $data['included_query'];
107
        $this->excludedQuery = $data['excluded_query'];
108
        $this->ignoredQuery  = $data['ignored_query'];
109
110
        $this->headers = $data['headers'];
111
    }
112
113
    /**
114
     * Default middleware options.
115
     *
116
     * @return array
117
     */
118
    public function defaults()
119
    {
120
        return [
121
            'ttl'            => CacheConfig::DAY_IN_SECONDS,
122
123
            'included_path'  => '*',
124
            'excluded_path'  => [ '^/admin\b' ],
125
126
            'methods'        => [ 'GET' ],
127
            'status_codes'   => [ 200 ],
128
129
            'included_query' => null,
130
            'excluded_query' => null,
131
            'ignored_query'  => null,
132
133
            'headers'        => true
134
        ];
135
    }
136
137
    /**
138
     * Load a route content from path's cache.
139
     *
140
     * This method is as dumb / simple as possible.
141
     * It does not rely on any sort of settings / configuration.
142
     * Simply: if the cache for the route exists, it will be used to display the page.
143
     * The `$next` callback will not be called, therefore stopping the middleware stack.
144
     *
145
     * To generate the cache used in this middleware,
146
     * @see \Charcoal\App\Middleware\CacheGeneratorMiddleware.
147
     *
148
     * @param RequestInterface  $request  The PSR-7 HTTP request.
149
     * @param ResponseInterface $response The PSR-7 HTTP response.
150
     * @param callable          $next     The next middleware callable in the stack.
151
     * @return ResponseInterface
152
     */
153
    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next)
154
    {
155
        $path = $request->getUri()->getPath();
156
        $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...
157
        $cacheKey  = $this->cacheKey($path, $queryParams, $request->getMethod());
158
159
        if ($this->cachePool->hasItem($cacheKey)) {
160
            $cacheItem = $this->cachePool->getItem($cacheKey);
161
            $cached = $cacheItem->get();
162
            $response->getBody()->write($cached['body']);
163
            foreach ($cached['headers'] as $name => $header) {
164
                $response = $response->withHeader($name, $header);
165
            }
166
167
            return $response;
168
        } else {
169
            $response = $next($request, $response);
170
171
            if ($this->isActive($request, $response) === false) {
172
                return $response;
173
            }
174
175
            if ($this->isPathIncluded($path) === false) {
176
                return $response;
177
            }
178
179
            if ($this->isPathExcluded($path) === true) {
180
                return $response;
181
            }
182
183
            if ($this->isQueryIncluded($queryParams) === false) {
184
                $keyParams = $this->parseIgnoredParams($queryParams);
185
                if (!empty($keyParams)) {
186
                    return $response;
187
                }
188
            }
189
190
            if ($this->isQueryExcluded($queryParams) === true) {
191
                return $response;
192
            }
193
194
            // Nothing has excluded the cache so far: add it to the pool.
195
            $cacheItem = $this->cachePool->getItem($cacheKey);
196
            $cacheItem->expiresAfter($this->cacheTtl);
197
            $cacheItem = $cacheItem->set([
198
                'body'    => (string)$response->getBody(),
199
                'headers' => (array)$response->getHeaders()
200
            ]);
201
            $this->cachePool->save($cacheItem);
202
203
            return $response;
204
        }
205
    }
206
207
    /**
208
     * @param string $path        The query path (route).
209
     * @param array  $queryParams The query parameters.
210
     * @param string $method      The request method.
211
     * @return string
212
     */
213
    private function cacheKey($path, array $queryParams, $method)
214
    {
215
        $cacheKey  = 'request/'.$method.'-'.str_replace('/', '.', $path);
216
        if (!empty($queryParams)) {
217
            $keyParams = $this->parseIgnoredParams($queryParams);
218
            $cacheKey .= '.'.md5(json_encode($keyParams));
219
        }
220
221
        return $cacheKey;
222
    }
223
224
    /**
225
     * @param RequestInterface  $request  The PSR-7 HTTP request.
226
     * @param ResponseInterface $response The PSR-7 HTTP response.
227
     * @return boolean
228
     */
229
    private function isActive(RequestInterface $request, ResponseInterface $response)
230
    {
231
        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...
232
            return false;
233
        }
234
235
        if (!in_array($request->getMethod(), $this->methods)) {
236
            return false;
237
        }
238
239
        return true;
240
    }
241
242
    /**
243
     * @param string $path The request path (route) to verify.
244
     * @return boolean
245
     */
246 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...
247
    {
248
        if ($this->includedPath === '*') {
249
            return true;
250
        }
251
252
        if (!$this->includedPath || empty($this->includedPath)) {
253
            return false;
254
        }
255
256
        if (is_string($this->includedPath)) {
257
            return !!(preg_match('@'.$this->includedPath.'@', $path));
258
        }
259
260
        if (is_array($this->includedPath)) {
261
            foreach ($this->includedPath as $included) {
262
                if (preg_match('@'.$included.'@', $path)) {
263
                    return true;
264
                }
265
            }
266
            return false;
267
        }
268
    }
269
270
    /**
271
     * @param string $path The request path (route) to verify.
272
     * @return boolean
273
     */
274 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...
275
    {
276
        if ($this->excludedPath === '*') {
277
            return true;
278
        }
279
280
        if (!$this->excludedPath || empty($this->excludedPath)) {
281
            return false;
282
        }
283
284
        if (is_string($this->excludedPath)) {
285
            return !!(preg_match('@'.$this->excludedPath.'@', $path));
286
        }
287
288
        if (is_array($this->excludedPath)) {
289
            foreach ($this->excludedPath as $excluded) {
290
                if (preg_match('@'.$excluded.'@', $path)) {
291
                    return true;
292
                }
293
            }
294
            return false;
295
        }
296
    }
297
298
    /**
299
     * @param array $queryParams The query parameters.
300
     * @return boolean
301
     */
302 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...
303
    {
304
        if (empty($queryParams)) {
305
            return true;
306
        }
307
308
        if ($this->includedQuery === '*') {
309
            return true;
310
        }
311
312
        if (!is_array($this->includedQuery) || empty($this->includedQuery)) {
313
            return false;
314
        }
315
316
        return (count(array_intersect_key($queryParams, $this->includedQuery)) > 0);
317
    }
318
319
    /**
320
     * @param array $queryParams The query parameters.
321
     * @return boolean
322
     */
323 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...
324
    {
325
        if ($this->excludedQuery === '*') {
326
            return true;
327
        }
328
329
        if (!is_array($this->excludedQuery) || empty($this->excludedQuery)) {
330
            return false;
331
        }
332
333
        if (count(array_intersect_key($queryParams, array_flip($this->excludedQuery))) > 0) {
334
            return true;
335
        }
336
    }
337
338
    /**
339
     * @param array $queryParams The query parameters.
340
     * @return array
341
     */
342
    private function parseIgnoredParams(array $queryParams)
343
    {
344
        if ($this->ignoredQuery === '*') {
345
            $ret = [];
346
            if (is_array($this->includedQuery)) {
347
                foreach ($queryParams as $k => $v) {
348
                    if (in_array($k, $this->includedQuery)) {
349
                        $ret[$k] = $v;
350
                    }
351
                }
352
            }
353
            return $ret;
354
        }
355
356
        if (!is_array($this->ignoredQuery) || empty($this->ignoredQuery)) {
357
            return $queryParams;
358
        }
359
360
        $ret = [];
361
        foreach ($queryParams as $k => $v) {
362
            if (!in_array($k, $this->ignoredQuery)) {
363
                $ret[$k] = $v;
364
            }
365
        }
366
367
        return $queryParams;
368
    }
369
}
370