Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport before throttle event from 2.x #43

Merged
merged 6 commits into from
Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ The above example would allow 300 requests/hour/token and would first try to
identify the client by JWT Bearer token before falling back to (Throttle default)
IP address based identification.

### Events

The middleware also dispatches following event which effectively allows you to
skip throttling:

#### `Throttle.beforeThrottle`

This is the first event that is triggered before a request is processed by the
middleware. All rate limiting process will be bypassed if this event is stopped.

```php
\Cake\Event\EventManager::instance()->on(
\Muffin\Throttle\Middleware\ThrottleMiddleware::EVENT_BEFORE_THROTTLE,
function ($event, $request) {
if (/* check for something here, most likely using $request */) {
$event->stopPropogation();
}
}
);
```

### X-headers

By default Throttle will add X-headers with rate limiting information
Expand Down
10 changes: 9 additions & 1 deletion src/Middleware/ThrottleMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@

class ThrottleMiddleware
{

use InstanceConfigTrait;
use ThrottleTrait;

const EVENT_BEFORE_THROTTLE = 'Throttle.beforeThrottle';

/**
* Default Configuration array
*
Expand Down Expand Up @@ -40,6 +41,13 @@ public function __construct($config = [])
*/
public function __invoke(ServerRequest $request, Response $response, callable $next)
{
$event = $this->dispatchEvent(self::EVENT_BEFORE_THROTTLE, [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implements EventDispatcherInterface should also be added to the middleware/filter classes.

'request' => $request,
]);
if ($event->isStopped()) {
return $next($request, $response);
}

$this->_setIdentifier($request);
$this->_initCache();
$this->_count = $this->_touch();
Expand Down
14 changes: 12 additions & 2 deletions src/Routing/Filter/ThrottleFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

class ThrottleFilter extends DispatcherFilter
{

use ThrottleTrait;

const EVENT_BEFORE_THROTTLE = 'Throttle.beforeThrottle';

/**
* Class constructor.
*
Expand All @@ -31,6 +32,15 @@ public function __construct($config = [])
*/
public function beforeDispatch(Event $event)
{
$_event = $this->dispatchEvent(self::EVENT_BEFORE_THROTTLE, [
'request' => $event->getData('request'),
]);
if ($_event->isStopped()) {
$event->stopPropagation();

return;
}

$this->_setIdentifier($event->getData('request'));
$this->_initCache();
$this->_count = $this->_touch();
Expand All @@ -55,7 +65,7 @@ public function beforeDispatch(Event $event)
$response = new Response([
'body' => $message,
'status' => 429,
'type' => $config['response']['type']
'type' => $config['response']['type'],
]);

if (is_array($config['response']['headers'])) {
Expand Down
6 changes: 4 additions & 2 deletions src/ThrottleTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
namespace Muffin\Throttle;

use Cake\Cache\Cache;
use Cake\Event\EventDispatcherTrait;

trait ThrottleTrait
{
use EventDispatcherTrait;

/*
* Default config for Throttle Middleware
Expand All @@ -15,15 +17,15 @@ trait ThrottleTrait
'response' => [
'body' => 'Rate limit exceeded',
'type' => 'text/html',
'headers' => []
'headers' => [],
],
'interval' => '+1 minute',
'limit' => 10,
'headers' => [
'limit' => 'X-RateLimit-Limit',
'remaining' => 'X-RateLimit-Remaining',
'reset' => 'X-RateLimit-Reset',
]
],
];

/**
Expand Down
95 changes: 78 additions & 17 deletions tests/TestCase/Middleware/ThrottleMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
use Cake\Cache\Cache;
use Cake\Cache\Engine\ApcEngine;
use Cake\Cache\Engine\ApcuEngine;
use Cake\Event\Event;
use Cake\Event\EventManager;
use Cake\Http\Response;
use Cake\Http\ServerRequest;
use Cake\TestSuite\TestCase;
use Muffin\Throttle\Middleware\ThrottleMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use StdClass;

class ThrottleMiddlewareTest extends TestCase
Expand Down Expand Up @@ -52,7 +55,7 @@ public function testConstructor()
$expectedHeaders = [
'limit' => 'X-RateLimit-Limit',
'remaining' => 'X-RateLimit-Remaining',
'reset' => 'X-RateLimit-Reset'
'reset' => 'X-RateLimit-Reset',
];
$this->assertEquals($expectedHeaders, $result['headers']);
}
Expand Down Expand Up @@ -80,7 +83,7 @@ public function testInvoke()
Cache::drop('throttle');
Cache::setConfig('throttle', [
'className' => $this->engineClass,
'prefix' => 'throttle_'
'prefix' => 'throttle_',
]);

$middleware = new ThrottleMiddleware([
Expand All @@ -89,16 +92,16 @@ public function testInvoke()
'body' => 'Rate limit exceeded',
'type' => 'json',
'headers' => [
'Custom-Header' => 'test/test'
]
]
'Custom-Header' => 'test/test',
],
],
]);

$response = new Response();
$request = new ServerRequest([
'environment' => [
'REMOTE_ADDR' => '192.168.1.33'
]
'REMOTE_ADDR' => '192.168.1.33',
],
]);

$result = $middleware(
Expand All @@ -112,7 +115,7 @@ function ($request, $response) {
$expectedHeaders = [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset'
'X-RateLimit-Reset',
];

$this->assertInstanceOf('Cake\Http\Response', $result);
Expand All @@ -129,7 +132,7 @@ function ($request, $response) {

$expectedHeaders = [
'Custom-Header',
'Content-Type'
'Content-Type',
];

$this->assertInstanceOf('Cake\Http\Response', $result);
Expand All @@ -151,7 +154,7 @@ public function testFileCacheException()
{
Cache::setConfig('file', [
'className' => 'Cake\Cache\Engine\FileEngine',
'prefix' => 'throttle_'
'prefix' => 'throttle_',
]);

$middleware = new ThrottleMiddleware();
Expand All @@ -176,7 +179,7 @@ public function testSetIdentifierMethod()

// should throw an exception if identifier is not a callable
$middleware = new ThrottleMiddleware([
'identifier' => 'non-callable-string'
'identifier' => 'non-callable-string',
]);
$reflection = $this->getReflection($middleware, '_setIdentifier');
$reflection->method->invokeArgs($middleware, [new ServerRequest()]);
Expand All @@ -189,7 +192,7 @@ public function testInitCacheMethod()
{
Cache::drop('default');
Cache::setConfig('default', [
'className' => 'Cake\Cache\Engine\FileEngine'
'className' => 'Cake\Cache\Engine\FileEngine',
]);

// test if new cache config is created if it does not exist
Expand All @@ -202,7 +205,7 @@ public function testInitCacheMethod()
$expected = [
'className' => 'File',
'prefix' => 'throttle_',
'duration' => '+1 minute'
'duration' => '+1 minute',
];

$this->assertEquals($expected, Cache::getConfig('throttle'));
Expand All @@ -223,7 +226,7 @@ public function testGetDefaultCacheConfigClassNameMethod()
// Make sure short cache engine names get resolved properly
Cache::drop('default');
Cache::setConfig('default', [
'className' => 'File'
'className' => 'File',
]);

$expected = 'File';
Expand All @@ -233,7 +236,7 @@ public function testGetDefaultCacheConfigClassNameMethod()
// Make sure fully namespaced cache engine names get resolved properly
Cache::drop('default');
Cache::setConfig('default', [
'className' => 'Cake\Cache\Engine\FileEngine'
'className' => 'Cake\Cache\Engine\FileEngine',
]);
$expected = 'File';
$result = $reflection->method->invokeArgs($middleware, [new ServerRequest()]);
Expand All @@ -248,7 +251,7 @@ public function testTouchMethod()
Cache::drop('throttle');
Cache::setConfig('throttle', [
'className' => $this->engineClass,
'prefix' => 'throttle_'
'prefix' => 'throttle_',
]);

$middleware = new ThrottleMiddleware();
Expand Down Expand Up @@ -298,7 +301,7 @@ public function testSetHeadersMethod()
{
// test disabled headers, should return null
$middleware = new ThrottleMiddleware([
'headers' => false
'headers' => false,
]);
$reflection = $this->getReflection($middleware, '_setHeaders');
$result = $reflection->method->invokeArgs($middleware, [new Response()]);
Expand Down Expand Up @@ -330,6 +333,64 @@ public function testRemainingConnectionsMethod()
$this->assertEquals('0', $result);
}

/**
* Test skipping rate limiting a request if propogation of ThrottleMiddleware::EVENT_BEFORE_THROTTLE
* is stopped.
*
* @return void
*/
public function testSkippingRequest()
{
Cache::drop('throttle');
Cache::setConfig('throttle', [
'className' => $this->engineClass,
'prefix' => 'throttle_',
]);

$middleware = new ThrottleMiddleware([
'limit' => 100,
]);

$response = new Response();
$request = new ServerRequest([
'environment' => [
'REMOTE_ADDR' => '192.168.1.33',
],
]);

$result = $middleware(
$request,
$response,
function ($request, $response) {
return $response;
}
);

$this->assertInstanceOf(Response::class, $result);
$this->assertNotEmpty($result->getHeaderLine('X-RateLimit-Limit'));

EventManager::instance()->on(
ThrottleMiddleware::EVENT_BEFORE_THROTTLE,
[],
function (Event $event, ServerRequestInterface $request) {
$event->stopPropagation();
}
);

$result = $middleware(
$request,
$response,
function ($request, $response) {
return $response;
}
);

$this->assertInstanceOf(Response::class, $result);
$this->assertEmpty($result->getHeaderLine('X-RateLimit-Limit'));

EventManager::instance()->off(ThrottleMiddleware::EVENT_BEFORE_THROTTLE);
}

/**
* Convenience function to return an object with reflection class, accessible
* protected method and optional accessible protected property.
Expand Down
Loading