Skip to content

Commit b1dd3d1

Browse files
authored
Merge pull request #4 from M6Web/feat/graphql-rate-limit
feat(graphql): enable graphql rate limiting
2 parents 3790565 + 24e126b commit b1dd3d1

11 files changed

Lines changed: 492 additions & 17 deletions

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,20 @@ ${SOURCE_DIR}/vendor/composer/installed.json:
4242

4343
.PHONY: phpunit
4444
phpunit:
45+
$(COMPOSER) require webonyx/graphql-php
4546
$(call printSection,TEST phpunit)
46-
${BIN_DIR}/phpunit
47+
${BIN_DIR}/phpunit --exclude withoutGraphQLPackage
48+
$(COMPOSER) remove webonyx/graphql-php
49+
${BIN_DIR}/phpunit --group withoutGraphQLPackage
4750

4851
### QUALITY ###
4952

5053
.PHONY: phpstan
5154
phpstan:
55+
$(COMPOSER) require webonyx/graphql-php
5256
$(call printSection,QUALITY phpstan)
5357
${BIN_DIR}/phpstan analyse --memory-limit=1G
58+
$(COMPOSER) remove webonyx/graphql-php
5459

5560
.PHONY: cs-ci
5661
cs-ci:

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ By default, the limitation is common to all routes annotated `@RateLimit()`.
3030
For example, if you keep the default configuration and you configure the `@RateLimit()` annotation in 2 routes. Limit will shared between this 2 routes, if user consume all authorized calls on the first route, the second route couldn't be called.
3131
If you swicth `limit_by_route` to true, users will be allowed to reach the limit on each route annotated.
3232

33+
`@GraphQLRateLimit()`annotation allows you to rate limit by graphQL query or mutation.
34+
/!\ To use this annotation, you will need to install suggested package.
35+
3336
If you switch `display_headers` to true, 3 headers will be added `x-rate-limit`, `x-rate-limit-hits`, `x-rate-limit-untils` to your responses. This can be usefull to debug your limitations.
3437
`display_headers` is used to display a verbose return if limit is reached.
3538

@@ -84,3 +87,17 @@ This annotation accepts parameters to customize the rate limit. The following ex
8487
* )
8588
*/
8689
```
90+
91+
To rate limit your graphQL API, add the `@GraphQLRateLimit()` annotation to your graphQL controller.
92+
This annotation requires a list of endpoints and accepts parameters to customize the rate limit. The following example shows how to limit requests on an endpoint at the rate of 10 requests max every 2 minutes and on default limitations.
93+
94+
```php
95+
/**
96+
* @GraphQLRateLimit(
97+
* endpoints={
98+
* {"endpoint"="GetMyQuery", "limit"=10, "period"=120},
99+
* {"endpoint"="EditMyMutation"},
100+
* }
101+
* )
102+
*/
103+
```

composer.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
"php": "7.4.*",
2020
"ext-json": "*",
2121
"doctrine/annotations": "^1.10.0",
22-
"symfony/dependency-injection": "4.4.*",
23-
"symfony/event-dispatcher": "4.4.*",
24-
"symfony/http-foundation": "4.4.*",
25-
"symfony/http-kernel": "4.4.*",
26-
"symfony/config": "4.4.*"
22+
"symfony/config": "4.4.*|^5.0",
23+
"symfony/dependency-injection": "4.4.*|^5.0",
24+
"symfony/event-dispatcher": "4.4.*|^5.0",
25+
"symfony/http-foundation": "4.4.*|^5.0",
26+
"symfony/http-kernel": "4.4.*|^5.0",
27+
"symfony/options-resolver": "^5.0"
2728
},
2829
"require-dev": {
2930
"phpunit/phpunit": "9.4.*",
@@ -32,6 +33,9 @@
3233
"phpstan/phpstan-phpunit": "0.12.*",
3334
"symfony/var-dumper": "4.4.*"
3435
},
36+
"suggest": {
37+
"webonyx/graphql-php": "Needed to support @GraphQLRateLimit annotation"
38+
},
3539
"autoload": {
3640
"psr-4": {
3741
"Bedrock\\Bundle\\RateLimitBundle\\": "src/"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bedrock\Bundle\RateLimitBundle\Annotation;
6+
7+
use Bedrock\Bundle\RateLimitBundle\Model\GraphQLEndpointConfiguration;
8+
use Symfony\Component\OptionsResolver\OptionsResolver;
9+
10+
/**
11+
* @Annotation
12+
* @Target({"METHOD"})
13+
*/
14+
final class GraphQLRateLimit
15+
{
16+
/** @var array<GraphQLEndpointConfiguration> */
17+
private array $endpointConfigurations;
18+
19+
/**
20+
* @param array<string, mixed> $args
21+
*/
22+
public function __construct(array $args = [])
23+
{
24+
$optionResolver = (new OptionsResolver())->setDefault('endpoints', function (OptionsResolver $endpointResolver) {
25+
$endpointResolver->setPrototype(true)
26+
->setDefaults([
27+
'limit' => null,
28+
'period' => null,
29+
])
30+
->setRequired('endpoint')
31+
->setAllowedTypes('endpoint', 'string')
32+
->setAllowedTypes('limit', ['int', 'null'])
33+
->setAllowedTypes('period', ['int', 'null']);
34+
});
35+
36+
$resolvedArgs = $optionResolver->resolve($args);
37+
38+
foreach ($resolvedArgs['endpoints'] as $endpoint) {
39+
$this->endpointConfigurations[] = new GraphQLEndpointConfiguration($endpoint['limit'], $endpoint['period'], $endpoint['endpoint']);
40+
}
41+
}
42+
43+
/**
44+
* @return array<GraphQLEndpointConfiguration>
45+
*/
46+
public function getEndpointConfigurations(): array
47+
{
48+
return $this->endpointConfigurations;
49+
}
50+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Bedrock\Bundle\RateLimitBundle\EventListener;
4+
5+
class QueryExtractionException extends \Exception
6+
{
7+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace Bedrock\Bundle\RateLimitBundle\EventListener;
4+
5+
use Bedrock\Bundle\RateLimitBundle\Annotation\GraphQLRateLimit as GraphQLRateLimitAnnotation;
6+
use Bedrock\Bundle\RateLimitBundle\Model\RateLimit;
7+
use Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RateLimitModifierInterface;
8+
use Doctrine\Common\Annotations\Reader;
9+
use GraphQL\Language\AST\OperationDefinitionNode;
10+
use GraphQL\Language\Parser;
11+
use GraphQL\Language\Source;
12+
use Symfony\Component\DependencyInjection\ContainerInterface;
13+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
14+
use Symfony\Component\HttpKernel\Event\ControllerEvent;
15+
16+
class ReadGraphQLRateLmitAnnotationListener implements EventSubscriberInterface
17+
{
18+
private Reader $annotationReader;
19+
/** @var iterable<RateLimitModifierInterface> */
20+
private $rateLimitModifiers;
21+
private int $limit;
22+
private int $period;
23+
private ContainerInterface $container;
24+
25+
/**
26+
* @param RateLimitModifierInterface[] $rateLimitModifiers
27+
*/
28+
public function __construct(ContainerInterface $container, Reader $annotationReader, iterable $rateLimitModifiers, int $limit, int $period)
29+
{
30+
foreach ($rateLimitModifiers as $rateLimitModifier) {
31+
if (!($rateLimitModifier instanceof RateLimitModifierInterface)) {
32+
throw new \InvalidArgumentException(('$rateLimitModifiers must be instance of '.RateLimitModifierInterface::class));
33+
}
34+
}
35+
36+
$this->annotationReader = $annotationReader;
37+
$this->rateLimitModifiers = $rateLimitModifiers;
38+
$this->limit = $limit;
39+
$this->period = $period;
40+
$this->container = $container;
41+
}
42+
43+
public function onKernelController(ControllerEvent $event): void
44+
{
45+
$request = $event->getRequest();
46+
// retrieve controller and method from request
47+
$controllerAttribute = $request->attributes->get('_controller', null);
48+
if (null === $controllerAttribute || !is_string($controllerAttribute)) {
49+
return;
50+
}
51+
// services alias can be used with 'service.alias:functionName' or 'service.alias::functionName'
52+
$controllerAttributeParts = explode(':', str_replace('::', ':', $controllerAttribute));
53+
$controllerName = $controllerAttributeParts[0] ?? '';
54+
$methodName = $controllerAttributeParts[1] ?? null;
55+
56+
if (!class_exists($controllerName)) {
57+
// If controller attribute is an alias instead of a class name
58+
if (null === ($controllerName = $this->container->get($controllerAttributeParts[0]))) {
59+
throw new \InvalidArgumentException('Parameter _controller from request : "'.$controllerAttribute.'" do not contains a valid class name');
60+
}
61+
}
62+
$reflection = new \ReflectionClass($controllerName);
63+
$annotation = $this->annotationReader->getMethodAnnotation($reflection->getMethod((string) ($methodName ?? '__invoke')), GraphQLRateLimitAnnotation::class);
64+
65+
if (!$annotation instanceof GraphQLRateLimitAnnotation) {
66+
return;
67+
}
68+
69+
if (!class_exists('GraphQL\Language\Parser')) {
70+
throw new \Exception('Run "composer require webonyx/graphql-php" to use @GraphQLRateLimit annotation.');
71+
}
72+
73+
$endpoint = $this->extractQueryName($request->request->get('query'));
74+
75+
foreach ($annotation->getEndpointConfigurations() as $graphQLEndpointConfiguration) {
76+
if ($endpoint === $graphQLEndpointConfiguration->getEndpoint()) {
77+
$rateLimit = new RateLimit(
78+
$graphQLEndpointConfiguration->getLimit() ?? $this->limit,
79+
$graphQLEndpointConfiguration->getPeriod() ?? $this->period
80+
);
81+
$rateLimit->varyHashOn('_graphql_endpoint', $endpoint);
82+
break;
83+
}
84+
}
85+
86+
if (!isset($rateLimit)) {
87+
return;
88+
}
89+
90+
foreach ($this->rateLimitModifiers as $hashKeyVarier) {
91+
if ($hashKeyVarier->support($request)) {
92+
$hashKeyVarier->modifyRateLimit($request, $rateLimit);
93+
}
94+
}
95+
$request->attributes->set('_rate_limit', $rateLimit);
96+
}
97+
98+
/**
99+
* @return array<string, string>
100+
*/
101+
public static function getSubscribedEvents(): array
102+
{
103+
return [
104+
ControllerEvent::class => 'onKernelController',
105+
];
106+
}
107+
108+
/**
109+
* @param string|int|float|bool|null $query
110+
*/
111+
public function extractQueryName($query): string
112+
{
113+
/** @var Source $query */
114+
$parsedQuery = Parser::parse($query);
115+
/** @var OperationDefinitionNode $item */
116+
foreach ($parsedQuery->definitions->getIterator() as $item) {
117+
/* @phpstan-ignore-next-line */
118+
return (string) $item->selectionSet->selections[0]->name->value;
119+
}
120+
121+
throw new QueryExtractionException('Unable to extract query');
122+
}
123+
}

src/EventListener/ReadRateLimitAnnotationListener.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,18 @@ public function onKernelController(ControllerEvent $event): void
6767
return;
6868
}
6969

70+
$rateLimit = new RateLimit(
71+
$this->limit,
72+
$this->period
73+
);
74+
7075
if ($this->limitByRoute) {
7176
$rateLimit = new RateLimit(
7277
$annotation->getLimit() ?? $this->limit,
7378
$annotation->getPeriod() ?? $this->period
7479
);
7580

7681
$rateLimit->varyHashOn('_route', $request->attributes->get('_route'));
77-
} else {
78-
$rateLimit = new RateLimit(
79-
$this->limit,
80-
$this->period
81-
);
8282
}
8383

8484
foreach ($this->rateLimitModifiers as $hashKeyVarier) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Bedrock\Bundle\RateLimitBundle\Model;
4+
5+
class GraphQLEndpointConfiguration
6+
{
7+
private ?int $limit;
8+
9+
private ?int $period;
10+
11+
private string $endpoint;
12+
13+
public function __construct(?int $limit, ?int $period, string $endpoint)
14+
{
15+
$this->limit = $limit;
16+
$this->period = $period;
17+
$this->endpoint = $endpoint;
18+
}
19+
20+
public function getEndpoint(): string
21+
{
22+
return $this->endpoint;
23+
}
24+
25+
public function getPeriod(): ?int
26+
{
27+
return $this->period;
28+
}
29+
30+
public function getLimit(): ?int
31+
{
32+
return $this->limit;
33+
}
34+
}

src/Resources/config/services.yml

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

16+
Bedrock\Bundle\RateLimitBundle\EventListener\ReadGraphQLRateLmitAnnotationListener:
17+
arguments:
18+
$limit: '%bedrock_rate_limit.limit%'
19+
$period: '%bedrock_rate_limit.period%'
20+
$rateLimitModifiers: !tagged rate_limit.modifiers
21+
1622
Bedrock\Bundle\RateLimitBundle\EventListener\LimitRateListener:
1723
arguments:
1824
$displayHeaders: '%bedrock_rate_limit.display_headers%'

0 commit comments

Comments
 (0)