UseMuffin / Throttle

@@ -1,80 +1,287 @@
Loading
1 1
<?php
2 +
declare(strict_types=1);
3 +
2 4
namespace Muffin\Throttle\Middleware;
3 5
6 +
use Cake\Cache\Cache;
4 7
use Cake\Core\InstanceConfigTrait;
5 8
use Cake\Event\EventDispatcherInterface;
9 +
use Cake\Event\EventDispatcherTrait;
6 10
use Cake\Http\Response;
7 -
use Cake\Http\ServerRequest;
8 -
use Muffin\Throttle\ThrottleTrait;
11 +
use Muffin\Throttle\ValueObject\RateLimitInfo;
12 +
use Muffin\Throttle\ValueObject\ThrottleInfo;
13 +
use Psr\Http\Message\ResponseInterface;
14 +
use Psr\Http\Message\ServerRequestInterface;
15 +
use Psr\Http\Server\MiddlewareInterface;
16 +
use Psr\Http\Server\RequestHandlerInterface;
17 +
use RuntimeException;
9 18
10 -
class ThrottleMiddleware implements EventDispatcherInterface
19 +
class ThrottleMiddleware implements MiddlewareInterface, EventDispatcherInterface
11 20
{
12 21
    use InstanceConfigTrait;
13 -
    use ThrottleTrait;
22 +
    use EventDispatcherTrait;
23 +
24 +
    public const EVENT_BEFORE_THROTTLE = 'Throttle.beforeThrottle';
25 +
26 +
    public const EVENT_GET_IDENTIFER = 'Throttle.getIdentifier';
14 27
15 -
    const EVENT_BEFORE_THROTTLE = 'Throttle.beforeThrottle';
28 +
    public const EVENT_GET_THROTTLE_INFO = 'Throttle.getThrottleInfo';
29 +
30 +
    public const EVENT_BEFORE_CACHE_SET = 'Throtttle.beforeCacheSet';
16 31
17 32
    /**
18 -
     * Default Configuration array
33 +
     * Default config.
19 34
     *
20 35
     * @var array
21 36
     */
22 -
    protected $_defaultConfig = [];
37 +
    protected $_defaultConfig = [
38 +
        'response' => [
39 +
            'body' => 'Rate limit exceeded',
40 +
            'type' => 'text/plain',
41 +
            'headers' => [],
42 +
        ],
43 +
        'period' => 60,
44 +
        'limit' => 60,
45 +
        'headers' => [
46 +
            'limit' => 'X-RateLimit-Limit',
47 +
            'remaining' => 'X-RateLimit-Remaining',
48 +
            'reset' => 'X-RateLimit-Reset',
49 +
        ],
50 +
        'cacheConfig' => 'throttle',
51 +
    ];
23 52
24 53
    /**
25 -
     * ThrottleMiddleware constructor.
54 +
     * Unique client identifier
55 +
     *
56 +
     * @var string
57 +
     */
58 +
    protected $_identifier;
59 +
60 +
    /**
61 +
     * Throttle Middleware constructor.
26 62
     *
27 63
     * @param array $config Configuration options
28 64
     */
29 -
    public function __construct($config = [])
65 +
    public function __construct(array $config = [])
30 66
    {
31 -
        $config = array_replace_recursive($this->_setConfiguration(), $config);
67 +
        $this->_defaultConfig['identifier'] = function ($request) {
68 +
            return $request->clientIp();
69 +
        };
70 +
71 +
        if (isset($config['interval'])) {
72 +
            $config['period'] = time() - strtotime($config['interval']);
73 +
            trigger_error(
74 +
                '`interval` config has been removed. Check the docs for replacement.',
75 +
                E_USER_WARNING
76 +
            );
77 +
        }
78 +
32 79
        $this->setConfig($config);
33 80
    }
34 81
35 82
    /**
36 -
     * Called when the class is used as a function
83 +
     * Process the request.
37 84
     *
38 -
     * @param \Cake\Http\ServerRequest $request Request object
39 -
     * @param \Cake\Http\Response $response Response object
40 -
     * @param callable $next Next class in middleware
41 -
     * @return \Psr\Http\Message\ResponseInterface
85 +
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
86 +
     * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
87 +
     * @return \Psr\Http\Message\ResponseInterface A response.
42 88
     */
43 -
    public function __invoke(ServerRequest $request, Response $response, callable $next)
89 +
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
44 90
    {
45 91
        $event = $this->dispatchEvent(self::EVENT_BEFORE_THROTTLE, [
46 92
            'request' => $request,
47 93
        ]);
48 94
        if ($event->isStopped()) {
49 -
            return $next($request, $response);
95 +
            return $handler->handle($request);
50 96
        }
51 97
52 98
        $this->_setIdentifier($request);
53 99
        $this->_initCache();
54 -
        $this->_count = $this->_touch();
55 100
101 +
        $throttle = $this->_getThrottle($request);
102 +
        $rateLimit = $this->_rateLimit($throttle);
103 +
104 +
        if ($rateLimit->limitExceeded()) {
105 +
            return $this->_getErrorResponse($rateLimit);
106 +
        }
107 +
108 +
        $response = $handler->handle($request);
109 +
110 +
        return $this->_setHeaders($response, $rateLimit);
111 +
    }
112 +
113 +
    /**
114 +
     * Return error response when rate limit is exceeded.
115 +
     *
116 +
     * @param \Muffin\Throttle\ValueObject\RateLimitInfo $rateLimit Rate limiting info.
117 +
     * @return \Psr\Http\Message\ResponseInterface
118 +
     */
119 +
    protected function _getErrorResponse(RateLimitInfo $rateLimit): ResponseInterface
120 +
    {
56 121
        $config = $this->getConfig();
57 122
58 -
        if ($this->_count > $config['limit']) {
59 -
            if (is_array($config['response']['headers'])) {
60 -
                foreach ($config['response']['headers'] as $name => $value) {
61 -
                    $response = $response->withHeader($name, $value);
62 -
                }
63 -
            }
123 +
        $response = new Response();
64 124
65 -
            if (isset($config['message'])) {
66 -
                $message = $config['message'];
67 -
            } else {
68 -
                $message = $config['response']['body'];
125 +
        if (is_array($config['response']['headers'])) {
126 +
            foreach ($config['response']['headers'] as $name => $value) {
127 +
                $response = $response->withHeader($name, $value);
69 128
            }
129 +
        }
130 +
131 +
        if (isset($config['message'])) {
132 +
            $message = $config['message'];
133 +
        } else {
134 +
            $message = $config['response']['body'];
135 +
        }
136 +
137 +
        $retryAfter = $rateLimit->getResetTimestamp() - time();
138 +
        $response = $response
139 +
            ->withStatus(429)
140 +
            ->withHeader('Retry-After', (string)$retryAfter)
141 +
            ->withType($config['response']['type'])
142 +
            ->withStringBody($message);
143 +
144 +
        return $this->_setHeaders($response, $rateLimit);
145 +
    }
146 +
147 +
    /**
148 +
     * Get throttling data.
149 +
     *
150 +
     * @param \Psr\Http\Message\ServerRequestInterface $request Server request instance.
151 +
     * @return \Muffin\Throttle\ValueObject\ThrottleInfo
152 +
     */
153 +
    protected function _getThrottle(ServerRequestInterface $request): ThrottleInfo
154 +
    {
155 +
        $throttle = new ThrottleInfo(
156 +
            $this->_identifier,
157 +
            $this->getConfig('limit'),
158 +
            $this->getConfig('period')
159 +
        );
160 +
161 +
        $event = $this->dispatchEvent(self::EVENT_GET_THROTTLE_INFO, [
162 +
            'request' => $request,
163 +
            'throttle' => $throttle,
164 +
        ]);
165 +
166 +
        return $event->getResult() ?? $event->getData()['throttle'];
167 +
    }
168 +
169 +
    /**
170 +
     * Rate limit the request.
171 +
     *
172 +
     * @param \Muffin\Throttle\ValueObject\ThrottleInfo $throttle Throttling info.
173 +
     * @return \Muffin\Throttle\ValueObject\RateLimitInfo
174 +
     */
175 +
    protected function _rateLimit(ThrottleInfo $throttle): RateLimitInfo
176 +
    {
177 +
        $key = $throttle->getKey();
178 +
        $currentTime = time();
179 +
        $ttl = $throttle->getPeriod();
180 +
        $cacheEngine = Cache::pool($this->getConfig('cacheConfig'));
181 +
182 +
        /** @var \Muffin\Throttle\ValueObject\RateLimitInfo|null $rateLimit */
183 +
        $rateLimit = $cacheEngine->get($key);
184 +
185 +
        if ($rateLimit === null || $currentTime > $rateLimit->getResetTimestamp()) {
186 +
            $rateLimit = new RateLimitInfo($throttle->getLimit(), 1, $currentTime + $throttle->getPeriod());
187 +
        } else {
188 +
            $rateLimit->incrementCalls();
189 +
        }
190 +
191 +
        if ($rateLimit->limitExceeded()) {
192 +
            $ttl = $rateLimit->getResetTimestamp() - $currentTime;
193 +
        }
194 +
195 +
        $event = $this->dispatchEvent(self::EVENT_BEFORE_CACHE_SET, [
196 +
            'rateLimit' => $rateLimit,
197 +
            'ttl' => $ttl,
198 +
            'throttleInfo' => clone $throttle,
199 +
        ]);
200 +
201 +
        $cacheEngine->set($key, $event->getData()['rateLimit'], $event->getData()['ttl']);
202 +
203 +
        return $rateLimit;
204 +
    }
205 +
206 +
    /**
207 +
     * Sets the identifier class property. By default used IP address
208 +
     * based identifier unless a callable alternative is passed.
209 +
     *
210 +
     * @param \Psr\Http\Message\ServerRequestInterface $request RequestInterface instance
211 +
     * @return string
212 +
     * @throws \InvalidArgumentException
213 +
     */
214 +
    protected function _setIdentifier(ServerRequestInterface $request): string
215 +
    {
216 +
        $event = $this->dispatchEvent(self::EVENT_GET_IDENTIFER, [
217 +
            'request' => $request,
218 +
        ]);
219 +
        $identifier = $event->getResult() ?: $this->getConfig('identifier')($request);
220 +
221 +
        if (!is_string($identifier)) {
222 +
            throw new RuntimeException('Throttle identifier must be a string.');
223 +
        }
224 +
225 +
        return $this->_identifier = $identifier;
226 +
    }
70 227
71 -
            return $response->withStatus(429)
72 -
                ->withType($config['response']['type'])
73 -
                ->withStringBody($message);
228 +
    /**
229 +
     * Initializes cache configuration.
230 +
     *
231 +
     * @return void
232 +
     */
233 +
    protected function _initCache(): void
234 +
    {
235 +
        $cacheConfig = $this->getConfig('cacheConfig');
236 +
237 +
        if (Cache::getConfig($cacheConfig) === null) {
238 +
            Cache::setConfig($cacheConfig, [
239 +
                'className' => $this->_getDefaultCacheConfigClassName(),
240 +
                'prefix' => $cacheConfig . '_' . $this->_identifier,
241 +
            ]);
242 +
        }
243 +
    }
244 +
245 +
    /**
246 +
     * Gets the className of the default CacheEngine so the Throttle cache
247 +
     * config can use the same. String cast is required to catch a DebugEngine
248 +
     * array/object for users with DebugKit enabled.
249 +
     *
250 +
     * @return string ClassName property of default Cache engine
251 +
     */
252 +
    protected function _getDefaultCacheConfigClassName(): string
253 +
    {
254 +
        $config = Cache::getConfig('default');
255 +
        $engine = (string)$config['className'];
256 +
257 +
        // short cache engine names can be returned immediately
258 +
        if (strpos($engine, '\\') === false) {
259 +
            return $engine;
74 260
        }
261 +
        // fully namespace cache engine names need extracting class name
262 +
        preg_match('/.+\\\\(.+)Engine/', $engine, $matches);
263 +
264 +
        return $matches[1];
265 +
    }
75 266
76 -
        $response = $next($request, $response);
267 +
    /**
268 +
     * Extends response with X-headers containing rate limiting information.
269 +
     *
270 +
     * @param \Psr\Http\Message\ResponseInterface $response ResponseInterface instance
271 +
     * @param \Muffin\Throttle\ValueObject\RateLimitInfo $rateLimit Rate limiting info.
272 +
     * @return \Psr\Http\Message\ResponseInterface
273 +
     */
274 +
    protected function _setHeaders(ResponseInterface $response, RateLimitInfo $rateLimit): ResponseInterface
275 +
    {
276 +
        $headers = $this->getConfig('headers');
277 +
278 +
        if (!is_array($headers)) {
279 +
            return $response;
280 +
        }
77 281
78 -
        return $this->_setHeaders($response);
282 +
        return $response
283 +
            ->withHeader($headers['limit'], (string)$rateLimit->getLimit())
284 +
            ->withHeader($headers['remaining'], (string)$rateLimit->getRemaining())
285 +
            ->withHeader($headers['reset'], (string)$rateLimit->getResetTimestamp());
79 286
    }
80 287
}
Files Complexity Coverage
src/Middleware/ThrottleMiddleware.php 23 92.13%
Project Totals (1 files) 23 92.13%
Sunburst
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Icicle
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.
Grid
Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively.
Loading