Skip to content

Commit

Permalink
Merge pull request #4 from M6Web/feat/graphql-rate-limit
Browse files Browse the repository at this point in the history
feat(graphql): enable graphql rate limiting
  • Loading branch information
SofLesc authored Aug 20, 2021
2 parents 3790565 + 24e126b commit b1dd3d1
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 17 deletions.
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,20 @@ ${SOURCE_DIR}/vendor/composer/installed.json:

.PHONY: phpunit
phpunit:
$(COMPOSER) require webonyx/graphql-php
$(call printSection,TEST phpunit)
${BIN_DIR}/phpunit
${BIN_DIR}/phpunit --exclude withoutGraphQLPackage
$(COMPOSER) remove webonyx/graphql-php
${BIN_DIR}/phpunit --group withoutGraphQLPackage

### QUALITY ###

.PHONY: phpstan
phpstan:
$(COMPOSER) require webonyx/graphql-php
$(call printSection,QUALITY phpstan)
${BIN_DIR}/phpstan analyse --memory-limit=1G
$(COMPOSER) remove webonyx/graphql-php

.PHONY: cs-ci
cs-ci:
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ By default, the limitation is common to all routes annotated `@RateLimit()`.
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.
If you swicth `limit_by_route` to true, users will be allowed to reach the limit on each route annotated.

`@GraphQLRateLimit()`annotation allows you to rate limit by graphQL query or mutation.
/!\ To use this annotation, you will need to install suggested package.

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.
`display_headers` is used to display a verbose return if limit is reached.

Expand Down Expand Up @@ -84,3 +87,17 @@ This annotation accepts parameters to customize the rate limit. The following ex
* )
*/
```

To rate limit your graphQL API, add the `@GraphQLRateLimit()` annotation to your graphQL controller.
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.

```php
/**
* @GraphQLRateLimit(
* endpoints={
* {"endpoint"="GetMyQuery", "limit"=10, "period"=120},
* {"endpoint"="EditMyMutation"},
* }
* )
*/
```
14 changes: 9 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
"php": "7.4.*",
"ext-json": "*",
"doctrine/annotations": "^1.10.0",
"symfony/dependency-injection": "4.4.*",
"symfony/event-dispatcher": "4.4.*",
"symfony/http-foundation": "4.4.*",
"symfony/http-kernel": "4.4.*",
"symfony/config": "4.4.*"
"symfony/config": "4.4.*|^5.0",
"symfony/dependency-injection": "4.4.*|^5.0",
"symfony/event-dispatcher": "4.4.*|^5.0",
"symfony/http-foundation": "4.4.*|^5.0",
"symfony/http-kernel": "4.4.*|^5.0",
"symfony/options-resolver": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "9.4.*",
Expand All @@ -32,6 +33,9 @@
"phpstan/phpstan-phpunit": "0.12.*",
"symfony/var-dumper": "4.4.*"
},
"suggest": {
"webonyx/graphql-php": "Needed to support @GraphQLRateLimit annotation"
},
"autoload": {
"psr-4": {
"Bedrock\\Bundle\\RateLimitBundle\\": "src/"
Expand Down
50 changes: 50 additions & 0 deletions src/Annotation/GraphQLRateLimit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Bedrock\Bundle\RateLimitBundle\Annotation;

use Bedrock\Bundle\RateLimitBundle\Model\GraphQLEndpointConfiguration;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* @Annotation
* @Target({"METHOD"})
*/
final class GraphQLRateLimit
{
/** @var array<GraphQLEndpointConfiguration> */
private array $endpointConfigurations;

/**
* @param array<string, mixed> $args
*/
public function __construct(array $args = [])
{
$optionResolver = (new OptionsResolver())->setDefault('endpoints', function (OptionsResolver $endpointResolver) {
$endpointResolver->setPrototype(true)
->setDefaults([
'limit' => null,
'period' => null,
])
->setRequired('endpoint')
->setAllowedTypes('endpoint', 'string')
->setAllowedTypes('limit', ['int', 'null'])
->setAllowedTypes('period', ['int', 'null']);
});

$resolvedArgs = $optionResolver->resolve($args);

foreach ($resolvedArgs['endpoints'] as $endpoint) {
$this->endpointConfigurations[] = new GraphQLEndpointConfiguration($endpoint['limit'], $endpoint['period'], $endpoint['endpoint']);
}
}

/**
* @return array<GraphQLEndpointConfiguration>
*/
public function getEndpointConfigurations(): array
{
return $this->endpointConfigurations;
}
}
7 changes: 7 additions & 0 deletions src/EventListener/QueryExtractionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Bedrock\Bundle\RateLimitBundle\EventListener;

class QueryExtractionException extends \Exception
{
}
123 changes: 123 additions & 0 deletions src/EventListener/ReadGraphQLRateLmitAnnotationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace Bedrock\Bundle\RateLimitBundle\EventListener;

use Bedrock\Bundle\RateLimitBundle\Annotation\GraphQLRateLimit as GraphQLRateLimitAnnotation;
use Bedrock\Bundle\RateLimitBundle\Model\RateLimit;
use Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RateLimitModifierInterface;
use Doctrine\Common\Annotations\Reader;
use GraphQL\Language\AST\OperationDefinitionNode;
use GraphQL\Language\Parser;
use GraphQL\Language\Source;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;

class ReadGraphQLRateLmitAnnotationListener implements EventSubscriberInterface
{
private Reader $annotationReader;
/** @var iterable<RateLimitModifierInterface> */
private $rateLimitModifiers;
private int $limit;
private int $period;
private ContainerInterface $container;

/**
* @param RateLimitModifierInterface[] $rateLimitModifiers
*/
public function __construct(ContainerInterface $container, Reader $annotationReader, iterable $rateLimitModifiers, int $limit, int $period)
{
foreach ($rateLimitModifiers as $rateLimitModifier) {
if (!($rateLimitModifier instanceof RateLimitModifierInterface)) {
throw new \InvalidArgumentException(('$rateLimitModifiers must be instance of '.RateLimitModifierInterface::class));
}
}

$this->annotationReader = $annotationReader;
$this->rateLimitModifiers = $rateLimitModifiers;
$this->limit = $limit;
$this->period = $period;
$this->container = $container;
}

public function onKernelController(ControllerEvent $event): void
{
$request = $event->getRequest();
// retrieve controller and method from request
$controllerAttribute = $request->attributes->get('_controller', null);
if (null === $controllerAttribute || !is_string($controllerAttribute)) {
return;
}
// services alias can be used with 'service.alias:functionName' or 'service.alias::functionName'
$controllerAttributeParts = explode(':', str_replace('::', ':', $controllerAttribute));
$controllerName = $controllerAttributeParts[0] ?? '';
$methodName = $controllerAttributeParts[1] ?? null;

if (!class_exists($controllerName)) {
// If controller attribute is an alias instead of a class name
if (null === ($controllerName = $this->container->get($controllerAttributeParts[0]))) {
throw new \InvalidArgumentException('Parameter _controller from request : "'.$controllerAttribute.'" do not contains a valid class name');
}
}
$reflection = new \ReflectionClass($controllerName);
$annotation = $this->annotationReader->getMethodAnnotation($reflection->getMethod((string) ($methodName ?? '__invoke')), GraphQLRateLimitAnnotation::class);

if (!$annotation instanceof GraphQLRateLimitAnnotation) {
return;
}

if (!class_exists('GraphQL\Language\Parser')) {
throw new \Exception('Run "composer require webonyx/graphql-php" to use @GraphQLRateLimit annotation.');
}

$endpoint = $this->extractQueryName($request->request->get('query'));

foreach ($annotation->getEndpointConfigurations() as $graphQLEndpointConfiguration) {
if ($endpoint === $graphQLEndpointConfiguration->getEndpoint()) {
$rateLimit = new RateLimit(
$graphQLEndpointConfiguration->getLimit() ?? $this->limit,
$graphQLEndpointConfiguration->getPeriod() ?? $this->period
);
$rateLimit->varyHashOn('_graphql_endpoint', $endpoint);
break;
}
}

if (!isset($rateLimit)) {
return;
}

foreach ($this->rateLimitModifiers as $hashKeyVarier) {
if ($hashKeyVarier->support($request)) {
$hashKeyVarier->modifyRateLimit($request, $rateLimit);
}
}
$request->attributes->set('_rate_limit', $rateLimit);
}

/**
* @return array<string, string>
*/
public static function getSubscribedEvents(): array
{
return [
ControllerEvent::class => 'onKernelController',
];
}

/**
* @param string|int|float|bool|null $query
*/
public function extractQueryName($query): string
{
/** @var Source $query */
$parsedQuery = Parser::parse($query);
/** @var OperationDefinitionNode $item */
foreach ($parsedQuery->definitions->getIterator() as $item) {
/* @phpstan-ignore-next-line */
return (string) $item->selectionSet->selections[0]->name->value;
}

throw new QueryExtractionException('Unable to extract query');
}
}
10 changes: 5 additions & 5 deletions src/EventListener/ReadRateLimitAnnotationListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,18 @@ public function onKernelController(ControllerEvent $event): void
return;
}

$rateLimit = new RateLimit(
$this->limit,
$this->period
);

if ($this->limitByRoute) {
$rateLimit = new RateLimit(
$annotation->getLimit() ?? $this->limit,
$annotation->getPeriod() ?? $this->period
);

$rateLimit->varyHashOn('_route', $request->attributes->get('_route'));
} else {
$rateLimit = new RateLimit(
$this->limit,
$this->period
);
}

foreach ($this->rateLimitModifiers as $hashKeyVarier) {
Expand Down
34 changes: 34 additions & 0 deletions src/Model/GraphQLEndpointConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Bedrock\Bundle\RateLimitBundle\Model;

class GraphQLEndpointConfiguration
{
private ?int $limit;

private ?int $period;

private string $endpoint;

public function __construct(?int $limit, ?int $period, string $endpoint)
{
$this->limit = $limit;
$this->period = $period;
$this->endpoint = $endpoint;
}

public function getEndpoint(): string
{
return $this->endpoint;
}

public function getPeriod(): ?int
{
return $this->period;
}

public function getLimit(): ?int
{
return $this->limit;
}
}
6 changes: 6 additions & 0 deletions src/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ services:
$limitByRoute: '%bedrock_rate_limit.limit_by_route%'
$rateLimitModifiers: !tagged rate_limit.modifiers

Bedrock\Bundle\RateLimitBundle\EventListener\ReadGraphQLRateLmitAnnotationListener:
arguments:
$limit: '%bedrock_rate_limit.limit%'
$period: '%bedrock_rate_limit.period%'
$rateLimitModifiers: !tagged rate_limit.modifiers

Bedrock\Bundle\RateLimitBundle\EventListener\LimitRateListener:
arguments:
$displayHeaders: '%bedrock_rate_limit.display_headers%'
Expand Down
Loading

0 comments on commit b1dd3d1

Please sign in to comment.