Skip to content

Commit cfed75a

Browse files
authored
Merge pull request #7 from jerlio/feat/set-ratelimit-by-route-in-yaml
feat: set ratelimit values by route in yaml files
2 parents b1dd3d1 + e92ba49 commit cfed75a

7 files changed

Lines changed: 271 additions & 0 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ Update your _config/services.yml_ like this:
7575
You can also create your own rate limit modifier by implementing `RateLimitModifierInterface` and tagging your service accordingly.
7676

7777
### Configure your routes
78+
79+
#### With annotations
80+
7881
Add the `@RateLimit()` annotation to your controller methods (by default, the limit will be 1000 requests per minute).
7982
This annotation accepts parameters to customize the rate limit. The following example shows how to limit requests on a route at the rate of 10 requests max every 2 minutes.
8083
:warning: This customization only works if the `limit_by_route` parameter is `true`
@@ -101,3 +104,21 @@ This annotation requires a list of endpoints and accepts parameters to customize
101104
* )
102105
*/
103106
```
107+
108+
#### In YAML
109+
110+
You also may add your rate limits in configuration files (Yaml) with the route name. If `period` or `limit` is not
111+
defined for a route, the bundle will take the common option.
112+
113+
```yaml
114+
bedrock_rate_limit:
115+
limit: 1000
116+
period: 60
117+
118+
routes:
119+
get_foobar:
120+
limit: 500
121+
period: 10
122+
post_foobar:
123+
period: 10
124+
```

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
</coverage>
99
<php>
1010
<ini name="error_reporting" value="-1"/>
11+
<ini name="date.timezone" value="UTC" />
1112
<server name="SHELL_VERBOSITY" value="-1"/>
1213
<env name="APP_ENV" value="test" />
1314
</php>

src/DependencyInjection/BedrockRateLimitExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function load(array $configs, ContainerBuilder $container): void
3131
$container->setParameter('bedrock_rate_limit.period', $config['period']);
3232
$container->setParameter('bedrock_rate_limit.limit_by_route', $config['limit_by_route']);
3333
$container->setParameter('bedrock_rate_limit.display_headers', $config['display_headers']);
34+
$container->setParameter('bedrock_rate_limit.routes', $config['routes']);
3435

3536
$loader = new YamlFileLoader(
3637
$container,

src/DependencyInjection/Configuration.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ public function getConfigTreeBuilder(): TreeBuilder
3232
->booleanNode('display_headers')
3333
->defaultValue(false)
3434
->end()
35+
->arrayNode('routes')
36+
->arrayPrototype()
37+
->children()
38+
->integerNode('limit')->end()
39+
->integerNode('period')->end()
40+
->end()
41+
->end()
42+
->end()
3543
->end()
3644
;
3745

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bedrock\Bundle\RateLimitBundle\EventListener;
6+
7+
use Bedrock\Bundle\RateLimitBundle\Model\RateLimit;
8+
use Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RateLimitModifierInterface;
9+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
10+
use Symfony\Component\HttpKernel\Event\ControllerEvent;
11+
12+
class ReadRateLimitConfigurationListener implements EventSubscriberInterface
13+
{
14+
/** @var iterable<RateLimitModifierInterface> */
15+
private $rateLimitModifiers;
16+
private int $limit;
17+
private int $period;
18+
/** @var array<string, array<string, int>> */
19+
private array $routes;
20+
21+
/**
22+
* @param RateLimitModifierInterface[] $rateLimitModifiers
23+
* @param array<string, array<string, int>> $routes
24+
*/
25+
public function __construct(iterable $rateLimitModifiers, int $limit, int $period, array $routes)
26+
{
27+
foreach ($rateLimitModifiers as $rateLimitModifier) {
28+
if (!($rateLimitModifier instanceof RateLimitModifierInterface)) {
29+
throw new \InvalidArgumentException(('$rateLimitModifiers must be instance of '.RateLimitModifierInterface::class));
30+
}
31+
}
32+
33+
$this->rateLimitModifiers = $rateLimitModifiers;
34+
$this->limit = $limit;
35+
$this->period = $period;
36+
$this->routes = $routes;
37+
}
38+
39+
public function onKernelController(ControllerEvent $event): void
40+
{
41+
$request = $event->getRequest();
42+
$routeName = strval($request->attributes->get('_route'));
43+
44+
if (!array_key_exists($routeName, $this->routes)) {
45+
return;
46+
}
47+
48+
$rateLimit = new RateLimit(
49+
$this->routes[$routeName]['limit'] ?? $this->limit,
50+
$this->routes[$routeName]['period'] ?? $this->period
51+
);
52+
53+
$rateLimit->varyHashOn('_route', $routeName);
54+
55+
foreach ($this->rateLimitModifiers as $hashKeyVarier) {
56+
if ($hashKeyVarier->support($request)) {
57+
$hashKeyVarier->modifyRateLimit($request, $rateLimit);
58+
}
59+
}
60+
61+
$request->attributes->set('_rate_limit', $rateLimit);
62+
}
63+
64+
/**
65+
* @return array<string, string>
66+
*/
67+
public static function getSubscribedEvents(): array
68+
{
69+
return [
70+
ControllerEvent::class => 'onKernelController',
71+
];
72+
}
73+
}

src/Resources/config/services.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ services:
1313
$limitByRoute: '%bedrock_rate_limit.limit_by_route%'
1414
$rateLimitModifiers: !tagged rate_limit.modifiers
1515

16+
Bedrock\Bundle\RateLimitBundle\EventListener\ReadRateLimitConfigurationListener:
17+
arguments:
18+
$limit: '%bedrock_rate_limit.limit%'
19+
$period: '%bedrock_rate_limit.period%'
20+
$rateLimitModifiers: !tagged rate_limit.modifiers
21+
$routes: '%bedrock_rate_limit.routes%'
22+
1623
Bedrock\Bundle\RateLimitBundle\EventListener\ReadGraphQLRateLmitAnnotationListener:
1724
arguments:
1825
$limit: '%bedrock_rate_limit.limit%'
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bedrock\Bundle\RateLimitBundle\Tests\EventListener;
6+
7+
use Bedrock\Bundle\RateLimitBundle\EventListener\ReadRateLimitConfigurationListener;
8+
use Bedrock\Bundle\RateLimitBundle\Model\RateLimit;
9+
use Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RateLimitModifierInterface;
10+
use PHPUnit\Framework\MockObject\MockObject;
11+
use PHPUnit\Framework\TestCase;
12+
use Symfony\Component\HttpFoundation\ParameterBag;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpKernel\Event\ControllerEvent;
15+
use Symfony\Component\HttpKernel\HttpKernelInterface;
16+
17+
class ReadRateLimitConfigurationListenerTest extends TestCase
18+
{
19+
private ReadRateLimitConfigurationListener $readRateLimitConfigurationListener;
20+
21+
/** @var array<RateLimitModifierInterface|MockObject> */
22+
private $rateLimitModifiers;
23+
24+
private int $limitDefaultValue = 1000;
25+
26+
private int $periodDefaultValue = 60;
27+
28+
/**
29+
* @param array<array<string, int>> $routes
30+
*/
31+
public function createReadRateLimitConfigurationListener(array $routes): void
32+
{
33+
$this->readRateLimitConfigurationListener = new ReadRateLimitConfigurationListener(
34+
$this->rateLimitModifiers = [
35+
$this->createMock(RateLimitModifierInterface::class),
36+
$this->createMock(RateLimitModifierInterface::class),
37+
],
38+
$this->limitDefaultValue,
39+
$this->periodDefaultValue,
40+
$routes
41+
);
42+
}
43+
44+
public function testItDoesNotSetRateLimitIfNoConfigurationProvided(): void
45+
{
46+
$this->createReadRateLimitConfigurationListener([]);
47+
$event = $this->createEvent();
48+
49+
$this->rateLimitModifiers[0]->expects($this->never())->method('support');
50+
$this->rateLimitModifiers[1]->expects($this->never())->method('support');
51+
52+
$this->readRateLimitConfigurationListener->onKernelController($event);
53+
$this->assertFalse($event->getRequest()->attributes->has('_rate_limit'));
54+
}
55+
56+
public function testItSetsRateLimitIfConfigurationProvidedWithDefaultValue(): void
57+
{
58+
$this->createReadRateLimitConfigurationListener(['some_route_name' => []]);
59+
$request = $this->createMock(Request::class);
60+
$request->attributes = new ParameterBag();
61+
$event = $this->createEvent($request);
62+
63+
$this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true);
64+
$rateLimit = new RateLimit($this->limitDefaultValue, $this->periodDefaultValue);
65+
$rateLimit->varyHashOn('_route', 'some_route_name');
66+
$this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit);
67+
68+
$this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false);
69+
$this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit');
70+
71+
$this->readRateLimitConfigurationListener->onKernelController($event);
72+
$this->assertTrue($event->getRequest()->attributes->has('_rate_limit'));
73+
74+
$this->assertEquals(
75+
$rateLimit,
76+
$event->getRequest()->attributes->get('_rate_limit')
77+
);
78+
}
79+
80+
public function testItSetsRateLimitIfConfigurationProvidedWithCustomValue(): void
81+
{
82+
$this->createReadRateLimitConfigurationListener([
83+
'some_route_name' => [
84+
'limit' => 10,
85+
'period' => 5,
86+
],
87+
]);
88+
$request = $this->createMock(Request::class);
89+
$request->attributes = new ParameterBag();
90+
$event = $this->createEvent($request);
91+
92+
$this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true);
93+
$rateLimit = new RateLimit(10, 5);
94+
$rateLimit->varyHashOn('_route', 'some_route_name');
95+
$this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit);
96+
97+
$this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false);
98+
$this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit');
99+
100+
$this->readRateLimitConfigurationListener->onKernelController($event);
101+
$this->assertTrue($event->getRequest()->attributes->has('_rate_limit'));
102+
103+
$this->assertEquals(
104+
$rateLimit,
105+
$event->getRequest()->attributes->get('_rate_limit')
106+
);
107+
}
108+
109+
public function testItSetsRateLimitIfConfigurationProvidedWithCustomValueOnMoreThanOneRoute(): void
110+
{
111+
$this->createReadRateLimitConfigurationListener([
112+
'some_route_name' => [
113+
'limit' => 100,
114+
],
115+
'an_other_route' => [
116+
'period' => 15,
117+
],
118+
]);
119+
$request = $this->createMock(Request::class);
120+
$request->attributes = new ParameterBag();
121+
$event = $this->createEvent($request);
122+
123+
$this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true);
124+
$rateLimit = new RateLimit(100, $this->periodDefaultValue);
125+
$rateLimit->varyHashOn('_route', 'some_route_name');
126+
$this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit);
127+
128+
$this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false);
129+
$this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit');
130+
131+
$this->readRateLimitConfigurationListener->onKernelController($event);
132+
$this->assertTrue($event->getRequest()->attributes->has('_rate_limit'));
133+
134+
$this->assertEquals(
135+
$rateLimit,
136+
$event->getRequest()->attributes->get('_rate_limit')
137+
);
138+
}
139+
140+
protected function createEvent(Request $request = null, string $serviceId = null): ControllerEvent
141+
{
142+
$request = $request ?? new Request();
143+
$request->attributes->set('_controller', $serviceId ?? FakeInvokableClass::class);
144+
$request->attributes->set('_route', 'some_route_name');
145+
146+
return new ControllerEvent(
147+
$this->createMock(HttpKernelInterface::class),
148+
new FakeInvokableClass(),
149+
$request,
150+
HttpKernelInterface::MASTER_REQUEST
151+
);
152+
}
153+
}
154+
155+
class FakeInvokableClass
156+
{
157+
public function __invoke(): void
158+
{
159+
}
160+
}

0 commit comments

Comments
 (0)