-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4 from M6Web/feat/graphql-rate-limit
feat(graphql): enable graphql rate limiting
- Loading branch information
Showing
11 changed files
with
492 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
123
src/EventListener/ReadGraphQLRateLmitAnnotationListener.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.