From 12b93aa0f41f738adb70a89e11ccdfba0fbd0bf2 Mon Sep 17 00:00:00 2001
From: Guillaume THIRARD <3818608+Melfo01@users.noreply.github.com>
Date: Wed, 18 Nov 2020 10:09:26 +0100
Subject: [PATCH] [WIP] - Initial commit from internal component (#1)
---
.editorconfig | 15 ++
.gitignore | 5 +
.php_cs | 11 +
Makefile | 64 ++++++
README.md | 86 +++++++
composer.json | 44 ++++
phpstan.neon.dist | 12 +
phpunit.xml.dist | 19 ++
src/Annotation/RateLimit.php | 48 ++++
.../BedrockRateLimitExtension.php | 41 ++++
src/DependencyInjection/Configuration.php | 40 ++++
.../AddRateLimitHeadersListener.php | 56 +++++
src/EventListener/LimitRateListener.php | 85 +++++++
.../ReadRateLimitAnnotationListener.php | 102 +++++++++
src/Model/RateLimit.php | 59 +++++
src/Model/StoredRateLimit.php | 70 ++++++
src/RateLimitBundle.php | 16 ++
.../HttpMethodRateLimitModifier.php | 23 ++
.../RateLimitModifierInterface.php | 13 ++
.../RequestAttributeRateLimitModifier.php | 26 +++
src/Resources/config/services.yml | 22 ++
...allyResetableRateLimitStorageInterface.php | 13 ++
src/Storage/RateLimitInMemoryStorage.php | 50 +++++
src/Storage/RateLimitStorageInterface.php | 24 ++
.../AddRateLimitHeadersListenerTest.php | 82 +++++++
.../BaseLimitRateListenerTest.php | 68 ++++++
tests/EventListener/LimitRateListenerTest.php | 176 +++++++++++++++
...ResetableRateLimitStorageInterfaceTest.php | 47 ++++
.../ReadRateLimitAnnotationListenerTest.php | 209 ++++++++++++++++++
tests/Model/RateLimitTest.php | 65 ++++++
tests/Model/StoredRateLimitTest.php | 48 ++++
.../HttpMethodRateLimitModifierTest.php | 50 +++++
.../RequestAttributeRateLimitModifierTest.php | 28 +++
33 files changed, 1717 insertions(+)
create mode 100644 .editorconfig
create mode 100644 .gitignore
create mode 100644 .php_cs
create mode 100644 Makefile
create mode 100644 composer.json
create mode 100644 phpstan.neon.dist
create mode 100644 phpunit.xml.dist
create mode 100644 src/Annotation/RateLimit.php
create mode 100644 src/DependencyInjection/BedrockRateLimitExtension.php
create mode 100644 src/DependencyInjection/Configuration.php
create mode 100644 src/EventListener/AddRateLimitHeadersListener.php
create mode 100644 src/EventListener/LimitRateListener.php
create mode 100644 src/EventListener/ReadRateLimitAnnotationListener.php
create mode 100644 src/Model/RateLimit.php
create mode 100644 src/Model/StoredRateLimit.php
create mode 100644 src/RateLimitBundle.php
create mode 100644 src/RateLimitModifier/HttpMethodRateLimitModifier.php
create mode 100644 src/RateLimitModifier/RateLimitModifierInterface.php
create mode 100644 src/RateLimitModifier/RequestAttributeRateLimitModifier.php
create mode 100644 src/Resources/config/services.yml
create mode 100644 src/Storage/ManuallyResetableRateLimitStorageInterface.php
create mode 100644 src/Storage/RateLimitInMemoryStorage.php
create mode 100644 src/Storage/RateLimitStorageInterface.php
create mode 100644 tests/EventListener/AddRateLimitHeadersListenerTest.php
create mode 100644 tests/EventListener/BaseLimitRateListenerTest.php
create mode 100644 tests/EventListener/LimitRateListenerTest.php
create mode 100644 tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php
create mode 100644 tests/EventListener/ReadRateLimitAnnotationListenerTest.php
create mode 100644 tests/Model/RateLimitTest.php
create mode 100644 tests/Model/StoredRateLimitTest.php
create mode 100644 tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php
create mode 100644 tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..54adb9b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[Makefile]
+indent_style = tab
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b63daac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/vendor/
+/bin/
+composer.lock
+.phpunit.result.cache
+.php_cs.cache
diff --git a/.php_cs b/.php_cs
new file mode 100644
index 0000000..ba4206f
--- /dev/null
+++ b/.php_cs
@@ -0,0 +1,11 @@
+getFinder()
+ ->in([
+ __DIR__.'/src',
+ __DIR__.'/tests'
+ ]);
+
+return $config;
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d1ec326
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,64 @@
+SHELL=bash -o pipefail
+SOURCE_DIR = $(shell pwd)
+BIN_DIR = ${SOURCE_DIR}/bin
+COMPOSER = composer
+
+define printSection
+ @printf "\033[36m\n==================================================\n\033[0m"
+ @printf "\033[36m $1 \033[0m"
+ @printf "\033[36m\n==================================================\n\033[0m"
+endef
+
+.PHONY: all
+all: install quality test
+
+.PHONY: ci
+ci: quality test
+
+.PHONY: install
+install: clean-vendor composer-install
+
+.PHONY: quality
+quality: cs-ci phpstan
+
+.PHONY: test
+test: phpunit
+
+### DEPENDENCIES ###
+
+.PHONY: clean-vendor
+clean-vendor:
+ $(call printSection,DEPENDENCIES clean)
+ rm -rf ${SOURCE_DIR}/vendor
+
+.PHONY: composer-install
+composer-install: ${SOURCE_DIR}/vendor/composer/installed.json
+
+${SOURCE_DIR}/vendor/composer/installed.json:
+ $(call printSection,DEPENDENCIES install)
+ $(COMPOSER) --no-interaction install --ansi --no-progress --prefer-dist
+
+### TEST ###
+
+.PHONY: phpunit
+phpunit:
+ $(call printSection,TEST phpunit)
+ ${BIN_DIR}/phpunit
+
+### QUALITY ###
+
+.PHONY: phpstan
+phpstan:
+ $(call printSection,QUALITY phpstan)
+ ${BIN_DIR}/phpstan analyse --memory-limit=1G
+
+.PHONY: cs-ci
+cs-ci:
+ $(call printSection,QUALITY php-cs-fixer check)
+ ${BIN_DIR}/php-cs-fixer fix --ansi --dry-run --using-cache=no --verbose
+
+.PHONY: cs-fix
+cs-fix:
+ $(call printSection,QUALITY php-cs-fixer fix)
+ ${BIN_DIR}/php-cs-fixer fix
+
diff --git a/README.md b/README.md
index e69de29..7cc3717 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,86 @@
+# RateLimitBundle
+This bundle provides an easy way to protect your project by limiting access to your controllers.
+
+## Install the bundle
+```bash
+composer require bedrock/rate-limit-bundle
+```
+
+Update your _config/bundles.php_ file to add the bundle for all env
+```php
+ ['all' => true],
+ ...
+];
+```
+
+### Configure the bundle
+Add the _config/packages/bedrock_rate_limit.yaml_ file with the following data.
+```yaml
+bedrock_rate_limit:
+ limit: 25 # 1000 requests by default
+ period: 600 # 60 seconds by default
+ limit_by_route: true|false # false by default
+ display_headers: true|false # false by default
+```
+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.
+
+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.
+
+### Configure your storage
+You must tell Symfony which storage implementation you want to use.
+
+Update your _config/services.yml_ like this:
+
+```yaml
+ ...
+ Bedrock\Bundle\RateLimitBundle\Storage\RateLimitStorageInterface: '@Bedrock\Bundle\RateLimitBundle\Storage\RateLimitInMemoryStorage'
+ ...
+```
+
+By default, only `RateLimitInMemory` is provided. But feel free to create your own by implementing `RateLimitStorageInterface` or `ManuallyResetableRateLimitStorageInterface`.
+If your database has a TTL system (like Redis), you can implement only `RateLimitStorageInterface`. Otherwhise you must implement also `ManuallyResetableRateLimitStorageInterface` to manually delete rate limit in your database.
+
+### Configure your modifiers
+Modifiers are a way to customize the rate limit.
+
+This bundle provides 2 modifiers:
+* `HttpMethodRateLimitModifier` limits the requests by `http_method`.
+* `RequestAttributeRateLimitModifier` limits the requests by attributes value (taken from the `$request->attributes` Symfony's bag).
+
+Update your _config/services.yml_ like this:
+
+```yaml
+ ...
+ Bedrock\Bundle\RateLimitBundle\RateLimitModifier\HttpMethodRateLimitModifier:
+ tags: [ 'rate_limit.modifiers' ]
+
+ Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RequestAttributeRateLimitModifier:
+ arguments:
+ $attributeName: 'myRequestAttribute'
+ tags: [ 'rate_limit.modifiers' ]
+
+ ...
+```
+
+You can also create your own rate limit modifier by implementing `RateLimitModifierInterface` and tagging your service accordingly.
+
+### Configure your routes
+Add the `@RateLimit()` annotation to your controller methods (by default, the limit will be 1000 requests per minute).
+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.
+:warning: This customization only works if the `limit_by_route` parameter is `true`
+
+```php
+/**
+* @RateLimit(
+* limit=10,
+* period=120
+* )
+*/
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..0064f38
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,44 @@
+{
+ "name": "bedrock/rate-limit-bundle",
+ "type": "symfony-bundle",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Bedrock",
+ "email": "opensource@bedrockstreaming.com",
+ "homepage": "https://tech.bedrockstreaming.com/"
+ }
+ ],
+ "config": {
+ "bin-dir": "bin",
+ "vendor-dir": "vendor",
+ "sort-packages": true
+ },
+ "require": {
+ "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.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "9.4.*",
+ "m6web/php-cs-fixer-config": "1.3.*",
+ "phpstan/phpstan": "0.12.*",
+ "phpstan/phpstan-phpunit": "0.12.*",
+ "symfony/var-dumper": "4.4.*"
+ },
+ "autoload": {
+ "psr-4": {
+ "Bedrock\\Bundle\\RateLimitBundle\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Bedrock\\Bundle\\RateLimitBundle\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..fa5a7d0
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,12 @@
+includes:
+ - 'vendor/phpstan/phpstan-phpunit/extension.neon'
+ - 'vendor/phpstan/phpstan-phpunit/rules.neon'
+
+parameters:
+ paths:
+ - 'src'
+ - 'tests'
+ level: 'max'
+ checkGenericClassInNonGenericObjectType: false
+ ignoreErrors:
+ - '#Cannot call method integerNode\(\) on Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface\|null\.#'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..deebbde
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+ tests
+
+
+
diff --git a/src/Annotation/RateLimit.php b/src/Annotation/RateLimit.php
new file mode 100644
index 0000000..3d48e39
--- /dev/null
+++ b/src/Annotation/RateLimit.php
@@ -0,0 +1,48 @@
+ $args
+ */
+ public function __construct(array $args = [])
+ {
+ $this->limit = $args['limit'] ?? null;
+ $this->period = $args['period'] ?? null;
+ }
+
+ public function getLimit(): ?int
+ {
+ return $this->limit;
+ }
+
+ public function setLimit(int $limit): RateLimit
+ {
+ $this->limit = $limit;
+
+ return $this;
+ }
+
+ public function getPeriod(): ?int
+ {
+ return $this->period;
+ }
+
+ public function setPeriod(int $period): RateLimit
+ {
+ $this->period = $period;
+
+ return $this;
+ }
+}
diff --git a/src/DependencyInjection/BedrockRateLimitExtension.php b/src/DependencyInjection/BedrockRateLimitExtension.php
new file mode 100644
index 0000000..4c9c209
--- /dev/null
+++ b/src/DependencyInjection/BedrockRateLimitExtension.php
@@ -0,0 +1,41 @@
+ $configs
+ */
+ public function load(array $configs, ContainerBuilder $container): void
+ {
+ $configuration = new Configuration();
+ $config = $this->processConfiguration($configuration, $configs);
+
+ $container->setParameter('bedrock_rate_limit.limit', $config['limit']);
+ $container->setParameter('bedrock_rate_limit.period', $config['period']);
+ $container->setParameter('bedrock_rate_limit.limit_by_route', $config['limit_by_route']);
+ $container->setParameter('bedrock_rate_limit.display_headers', $config['display_headers']);
+
+ $loader = new YamlFileLoader(
+ $container,
+ new FileLocator(__DIR__.'/../Resources/config')
+ );
+ $loader->load('services.yml');
+ }
+}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
new file mode 100644
index 0000000..803ba1c
--- /dev/null
+++ b/src/DependencyInjection/Configuration.php
@@ -0,0 +1,40 @@
+getRootNode();
+
+ $rootNode
+ ->children()
+ ->booleanNode('limit_by_route')
+ ->defaultValue(false)
+ ->end()
+ ->integerNode('limit')
+ ->defaultValue(1000)
+ ->min(0)
+ ->end()
+ ->integerNode('period')
+ ->defaultValue(60)
+ ->min(0)
+ ->end()
+ ->booleanNode('display_headers')
+ ->defaultValue(false)
+ ->end()
+ ->end()
+ ;
+
+ return $treeBuilder;
+ }
+}
diff --git a/src/EventListener/AddRateLimitHeadersListener.php b/src/EventListener/AddRateLimitHeadersListener.php
new file mode 100644
index 0000000..e973106
--- /dev/null
+++ b/src/EventListener/AddRateLimitHeadersListener.php
@@ -0,0 +1,56 @@
+displayHeaders = $displayHeaders;
+ }
+
+ public function onKernelResponse(ResponseEvent $event): void
+ {
+ if (!$this->displayHeaders) {
+ return;
+ }
+
+ if (!$event->isMasterRequest()) {
+ return;
+ }
+
+ $request = $event->getRequest();
+ if (!$request->attributes->has('_stored_rate_limit')) {
+ return;
+ }
+
+ $storedRateLimit = $request->attributes->get('_stored_rate_limit');
+
+ if (!$storedRateLimit instanceof StoredRateLimit) {
+ return;
+ }
+
+ $response = $event->getResponse();
+ $response->headers->set('x-rate-limit', (string) $storedRateLimit->getLimit());
+ $response->headers->set('x-rate-limit-hits', (string) $storedRateLimit->getHits());
+ $response->headers->set('x-rate-limit-until', $storedRateLimit->getValidUntil()->format('c'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ResponseEvent::class => 'onKernelResponse',
+ ];
+ }
+}
diff --git a/src/EventListener/LimitRateListener.php b/src/EventListener/LimitRateListener.php
new file mode 100644
index 0000000..5bfc69e
--- /dev/null
+++ b/src/EventListener/LimitRateListener.php
@@ -0,0 +1,85 @@
+storage = $storage;
+ $this->displayHeaders = $displayHeaders;
+ }
+
+ public function onKernelController(ControllerArgumentsEvent $event): void
+ {
+ if (!$event->isMasterRequest()) {
+ return;
+ }
+
+ $request = $event->getRequest();
+
+ if (!$request->attributes->has('_rate_limit')) {
+ return;
+ }
+
+ $rateLimit = $request->attributes->get('_rate_limit');
+
+ if (!$rateLimit instanceof RateLimit) {
+ throw new \InvalidArgumentException(sprintf('Request attribute "_rate_limit" should be of type "%s". "%s" given.', RateLimit::class, \is_object($rateLimit) ? \get_class($rateLimit) : \gettype($rateLimit)));
+ }
+
+ $storedRateLimit = $this->storage->getStoredRateLimit($rateLimit);
+
+ if (null !== $storedRateLimit
+ && $storedRateLimit->isOutdated()) {
+ if ($this->storage instanceof ManuallyResetableRateLimitStorageInterface) {
+ $this->storage->resetRateLimit($rateLimit);
+ }
+
+ $storedRateLimit = null;
+ }
+
+ if (null !== $storedRateLimit && $storedRateLimit->getHits() >= $rateLimit->getLimit()) {
+ $displayHeaders = $this->displayHeaders;
+ $event->setController(
+ static function () use ($displayHeaders, $storedRateLimit) {
+ return new JsonResponse(
+ $displayHeaders ? $storedRateLimit->getLimitReachedOutput() : Response::$statusTexts[Response::HTTP_TOO_MANY_REQUESTS],
+ Response::HTTP_TOO_MANY_REQUESTS
+ );
+ }
+ );
+ }
+
+ if (null === $storedRateLimit) {
+ $storedRateLimit = $this->storage->storeRateLimit($rateLimit);
+ } else {
+ $storedRateLimit = $this->storage->incrementHits($storedRateLimit);
+ }
+
+ $request->attributes->set('_stored_rate_limit', $storedRateLimit);
+ }
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ControllerArgumentsEvent::class => 'onKernelController',
+ ];
+ }
+}
diff --git a/src/EventListener/ReadRateLimitAnnotationListener.php b/src/EventListener/ReadRateLimitAnnotationListener.php
new file mode 100644
index 0000000..340e7b7
--- /dev/null
+++ b/src/EventListener/ReadRateLimitAnnotationListener.php
@@ -0,0 +1,102 @@
+ */
+ private $rateLimitModifiers;
+ private int $limit;
+ private int $period;
+ private bool $limitByRoute;
+ private ContainerInterface $container;
+
+ /**
+ * @param RateLimitModifierInterface[] $rateLimitModifiers
+ */
+ public function __construct(ContainerInterface $container, Reader $annotationReader, iterable $rateLimitModifiers, int $limit, int $period, bool $limitByRoute)
+ {
+ 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->limitByRoute = $limitByRoute;
+ $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;
+ }
+ $controllerAttributeParts = explode('::', $controllerAttribute);
+ $controllerName = $controllerAttributeParts[0] ?? '';
+ $methodName = $controllerAttributeParts[1] ?? null;
+
+ if (!class_exists($controllerName)) {
+ // If controller attribute is an alias instead of a class name
+ $serviceIdAttributeParts = explode(':', $controllerName);
+ if (null === ($controllerName = $this->container->get($serviceIdAttributeParts[0]))) {
+ throw new \InvalidArgumentException('Parameter _controller from request : "'.$controllerAttribute.'" do not contains a valid class name');
+ }
+ $methodName = $serviceIdAttributeParts[1] ?? null;
+ }
+ $reflection = new \ReflectionClass($controllerName);
+ $annotation = $this->annotationReader->getMethodAnnotation($reflection->getMethod((string) ($methodName ?? '__invoke')), RateLimitAnnotation::class);
+
+ if (!$annotation instanceof RateLimitAnnotation) {
+ return;
+ }
+
+ 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) {
+ if ($hashKeyVarier->support($request)) {
+ $hashKeyVarier->modifyRateLimit($request, $rateLimit);
+ }
+ }
+ $request->attributes->set('_rate_limit', $rateLimit);
+ }
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ControllerEvent::class => 'onKernelController',
+ ];
+ }
+}
diff --git a/src/Model/RateLimit.php b/src/Model/RateLimit.php
new file mode 100644
index 0000000..db94a2f
--- /dev/null
+++ b/src/Model/RateLimit.php
@@ -0,0 +1,59 @@
+ */
+ private array $vary = [];
+ private int $limit;
+ private int $period;
+
+ public function __construct(int $limit, int $period)
+ {
+ if ($limit < 0 || $period < 0) {
+ throw new \InvalidArgumentException('Limit and period must be > 0');
+ }
+
+ $this->limit = $limit;
+ $this->period = $period;
+ }
+
+ public function getLimit(): int
+ {
+ return $this->limit;
+ }
+
+ public function getPeriod(): int
+ {
+ return $this->period;
+ }
+
+ public function varyHashOn(string $key, string $value): void
+ {
+ if (array_key_exists($key, $this->vary)) {
+ throw new \InvalidArgumentException(sprintf('Key "%s" already exists in "vary" array.', $key));
+ }
+
+ $this->vary[$key] = $value;
+ }
+
+ public function getDiscriminator(): string
+ {
+ if (count($this->vary) === 0) {
+ throw new \InvalidArgumentException('Cannot compute rate limit discriminator with an empty vary.');
+ }
+
+ return (string) json_encode($this->vary);
+ }
+
+ /**
+ * @return string The current request's hash discriminator on which to calculate the rate limit
+ */
+ public function getHash(): string
+ {
+ return md5($this->getDiscriminator());
+ }
+}
diff --git a/src/Model/StoredRateLimit.php b/src/Model/StoredRateLimit.php
new file mode 100644
index 0000000..bd6ba44
--- /dev/null
+++ b/src/Model/StoredRateLimit.php
@@ -0,0 +1,70 @@
+rateLimit = $rateLimit;
+ $this->hits = $hits;
+ $this->validUntil = $validUntil;
+ }
+
+ public function getHash(): string
+ {
+ return $this->rateLimit->getHash();
+ }
+
+ public function getLimit(): int
+ {
+ return $this->rateLimit->getLimit();
+ }
+
+ public function getHits(): int
+ {
+ return $this->hits;
+ }
+
+ public function withHits(int $hits): self
+ {
+ $clone = clone $this;
+ $clone->hits = $hits;
+
+ return $clone;
+ }
+
+ public function isOutdated(): bool
+ {
+ return $this->validUntil < \DateTimeImmutable::createFromFormat('U', (string) time());
+ }
+
+ public function getValidUntil(): \DateTimeImmutable
+ {
+ return $this->validUntil;
+ }
+
+ /**
+ * @return array|int|string>
+ */
+ public function getLimitReachedOutput(): array
+ {
+ return [
+ 'message' => sprintf(
+ 'Too many requests. Only %d calls allowed every %d seconds.',
+ $this->getLimit(),
+ $this->rateLimit->getPeriod()
+ ),
+ 'limit' => $this->getLimit(),
+ 'period' => $this->rateLimit->getPeriod(),
+ 'until' => $this->getValidUntil()->format('Y-m-d H:i:s'),
+ 'vary' => $this->rateLimit->getDiscriminator(),
+ ];
+ }
+}
diff --git a/src/RateLimitBundle.php b/src/RateLimitBundle.php
new file mode 100644
index 0000000..9c6742f
--- /dev/null
+++ b/src/RateLimitBundle.php
@@ -0,0 +1,16 @@
+varyHashOn('http_method', $request->getMethod());
+ }
+}
diff --git a/src/RateLimitModifier/RateLimitModifierInterface.php b/src/RateLimitModifier/RateLimitModifierInterface.php
new file mode 100644
index 0000000..5bd17d1
--- /dev/null
+++ b/src/RateLimitModifier/RateLimitModifierInterface.php
@@ -0,0 +1,13 @@
+attributeName = $attributeName;
+ }
+
+ public function support(Request $request): bool
+ {
+ return $request->attributes->has($this->attributeName);
+ }
+
+ public function modifyRateLimit(Request $request, RateLimit $rateLimit): void
+ {
+ $rateLimit->varyHashOn($this->attributeName, $request->attributes->get($this->attributeName));
+ }
+}
diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml
new file mode 100644
index 0000000..0db27f2
--- /dev/null
+++ b/src/Resources/config/services.yml
@@ -0,0 +1,22 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+
+ Bedrock\Bundle\RateLimitBundle\:
+ resource: '../../../src/*'
+
+ Bedrock\Bundle\RateLimitBundle\EventListener\ReadRateLimitAnnotationListener:
+ arguments:
+ $limit: '%bedrock_rate_limit.limit%'
+ $period: '%bedrock_rate_limit.period%'
+ $limitByRoute: '%bedrock_rate_limit.limit_by_route%'
+ $rateLimitModifiers: !tagged rate_limit.modifiers
+
+ Bedrock\Bundle\RateLimitBundle\EventListener\LimitRateListener:
+ arguments:
+ $displayHeaders: '%bedrock_rate_limit.display_headers%'
+
+ Bedrock\Bundle\RateLimitBundle\EventListener\AddRateLimitHeadersListener:
+ arguments:
+ $displayHeaders: '%bedrock_rate_limit.display_headers%'
diff --git a/src/Storage/ManuallyResetableRateLimitStorageInterface.php b/src/Storage/ManuallyResetableRateLimitStorageInterface.php
new file mode 100644
index 0000000..6e41ddb
--- /dev/null
+++ b/src/Storage/ManuallyResetableRateLimitStorageInterface.php
@@ -0,0 +1,13 @@
+ */
+ private static $storedRateLimits = [];
+
+ public function getStoredRateLimit(RateLimit $rateLimit): ?StoredRateLimit
+ {
+ return self::$storedRateLimits[$rateLimit->getHash()] ?? null;
+ }
+
+ public function storeRateLimit(RateLimit $rateLimit): StoredRateLimit
+ {
+ $validUntil = \DateTimeImmutable::createFromFormat('U', (string) (time() + $rateLimit->getPeriod()));
+ if ($validUntil === false) {
+ throw new \RuntimeException('Invalid rate limit period');
+ }
+
+ self::$storedRateLimits[$rateLimit->getHash()] = new StoredRateLimit($rateLimit, 1, $validUntil);
+
+ return self::$storedRateLimits[$rateLimit->getHash()];
+ }
+
+ public function incrementHits(StoredRateLimit $storedRateLimit): StoredRateLimit
+ {
+ self::$storedRateLimits[$storedRateLimit->getHash()] = $storedRateLimit->withHits($storedRateLimit->getHits() + 1);
+
+ return self::$storedRateLimits[$storedRateLimit->getHash()];
+ }
+
+ public function resetRateLimit(RateLimit $rateLimit): void
+ {
+ if (array_key_exists($rateLimit->getHash(), self::$storedRateLimits)) {
+ unset(self::$storedRateLimits[$rateLimit->getHash()]);
+ }
+ }
+
+ public static function reset(): void
+ {
+ self::$storedRateLimits = [];
+ }
+}
diff --git a/src/Storage/RateLimitStorageInterface.php b/src/Storage/RateLimitStorageInterface.php
new file mode 100644
index 0000000..802a46b
--- /dev/null
+++ b/src/Storage/RateLimitStorageInterface.php
@@ -0,0 +1,24 @@
+onKernelResponse($event = $this->createEvent());
+
+ $response = $event->getResponse();
+ $this->assertFalse($response->headers->has('x-rate-limit'));
+ $this->assertFalse($response->headers->has('x-rate-limit-hits'));
+ $this->assertFalse($response->headers->has('x-rate-limit-until'));
+ }
+
+ public function testItSetsHeadersIfRateLimitProvidedInRequest(): void
+ {
+ $addRateLimitHeadersListener = new AddRateLimitHeadersListener(true);
+ $addRateLimitHeadersListener->onKernelResponse(
+ $event = $this->createEvent(
+ new Request(
+ [],
+ [],
+ [
+ '_stored_rate_limit' => new StoredRateLimit(
+ new RateLimit(1000, 60), 4, $validUntil = new \DateTimeImmutable()
+ ),
+ ]
+ )
+ )
+ );
+
+ $response = $event->getResponse();
+ $this->assertSame(1000, (int) $response->headers->get('x-rate-limit'));
+ $this->assertSame(4, (int) $response->headers->get('x-rate-limit-hits'));
+ $this->assertSame($validUntil->format('c'), $response->headers->get('x-rate-limit-until'));
+ }
+
+ public function testResponseHasNotHeaderIfDisplayHeadersIsDisable(): void
+ {
+ $addRateLimitHeadersListener = new AddRateLimitHeadersListener(false);
+ $addRateLimitHeadersListener->onKernelResponse(
+ $event = $this->createEvent(
+ new Request(
+ [],
+ [],
+ [
+ '_stored_rate_limit' => new StoredRateLimit(
+ new RateLimit(1000, 60), 4, $validUntil = new \DateTimeImmutable()
+ ),
+ ]
+ )
+ )
+ );
+
+ $response = $event->getResponse();
+ $this->assertFalse($response->headers->has('x-rate-limit'));
+ $this->assertFalse($response->headers->has('x-rate-limit-hits'));
+ $this->assertFalse($response->headers->has('x-rate-limit-until'));
+ }
+
+ private function createEvent(Request $request = null): ResponseEvent
+ {
+ return new ResponseEvent(
+ $this->createMock(HttpKernelInterface::class),
+ $request ?? new Request(),
+ HttpKernelInterface::MASTER_REQUEST,
+ new Response()
+ );
+ }
+}
diff --git a/tests/EventListener/BaseLimitRateListenerTest.php b/tests/EventListener/BaseLimitRateListenerTest.php
new file mode 100644
index 0000000..053cc2d
--- /dev/null
+++ b/tests/EventListener/BaseLimitRateListenerTest.php
@@ -0,0 +1,68 @@
+varyHashOn('http_method', 'GET');
+
+ return $this->createEvent(
+ new Request(
+ [],
+ [],
+ ['_rate_limit' => $rateLimit]
+ )
+ );
+ }
+
+ protected function createEvent(Request $request = null): ControllerArgumentsEvent
+ {
+ $controller = new class() {
+ /**
+ * @RateLimit()
+ */
+ public function action(): void
+ {
+ }
+ };
+
+ return new ControllerArgumentsEvent(
+ $this->createMock(HttpKernelInterface::class),
+ [$controller, 'action'],
+ [],
+ $request ?? new Request(),
+ HttpKernelInterface::MASTER_REQUEST
+ );
+ }
+
+ /**
+ * @return StoredRateLimit|MockObject
+ *
+ * Mocking this value object is needed otherwise RateLimit\FunctionalTest will fail
+ * because of a bug from outer space in ClockMock
+ */
+ protected function mockStoredRateLimit(RateLimit $rateLimit, int $hits, \DateTimeImmutable $validUntil)
+ {
+ $mock = $this->createMock(StoredRateLimit::class);
+ $mock->expects($this->any())->method('getHash')->willReturn($rateLimit->getHash());
+ $mock->expects($this->any())->method('getLimit')->willReturn($rateLimit->getLimit());
+ $mock->expects($this->any())->method('getHits')->willReturn($hits);
+ $mock->expects($this->any())->method('getValidUntil')->willReturn($validUntil);
+ $mock->expects($this->any())->method('isOutdated')->willReturn($validUntil < new \DateTimeImmutable());
+
+ return $mock;
+ }
+}
diff --git a/tests/EventListener/LimitRateListenerTest.php b/tests/EventListener/LimitRateListenerTest.php
new file mode 100644
index 0000000..2ee5eca
--- /dev/null
+++ b/tests/EventListener/LimitRateListenerTest.php
@@ -0,0 +1,176 @@
+limitRateListener = new LimitRateListener(
+ $this->storage = $this->createMock(RateLimitStorageInterface::class),
+ false
+ );
+ }
+
+ public function testItDoesNotCheckRateLimitIfItIsNotInRequest(): void
+ {
+ $event = $this->createEvent();
+
+ $this->storage->expects($this->never())->method('getStoredRateLimit');
+
+ $this->limitRateListener->onKernelController($event);
+
+ $this->assertFalse($event->getRequest()->attributes->has('_stored_rate_limit'));
+ }
+
+ public function testItStoresRateLimitIfNoneIsStored(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())
+ ->method('getStoredRateLimit')
+ ->with($rateLimit)
+ ->willReturn(null);
+
+ $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit);
+
+ $this->storage->expects($this->never())->method('incrementHits');
+
+ $oldController = $event->getController();
+ $this->limitRateListener->onKernelController($event);
+ $this->assertSame($oldController, $event->getController());
+ $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit'));
+ }
+
+ public function testItResetsAndStoresNewRateLimitIfCurrentOneIsOutdated(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())
+ ->method('getStoredRateLimit')
+ ->with($rateLimit)
+ ->willReturn(
+ $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1, new \DateTimeImmutable('1 day ago'))
+ );
+
+ $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit);
+
+ $this->storage->expects($this->never())->method('incrementHits');
+
+ $oldController = $event->getController();
+ $this->limitRateListener->onKernelController($event);
+ $this->assertSame($oldController, $event->getController());
+ $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit'));
+ }
+
+ public function testItDecreasesLimitIfRateLimitIsValid(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())
+ ->method('getStoredRateLimit')
+ ->with($rateLimit)
+ ->willReturn(
+ $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 4, new \DateTimeImmutable('+1 day'))
+ );
+
+ $this->storage->expects($this->once())->method('incrementHits')->with($storedRateLimit);
+
+ $this->storage->expects($this->never())->method('storeRateLimit');
+
+ $oldController = $event->getController();
+ $this->limitRateListener->onKernelController($event);
+ $this->assertSame($oldController, $event->getController());
+
+ $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit'));
+ }
+
+ public function testItSetsABlockingResponseIfLimitIsReached(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage
+ ->expects($this->once())
+ ->method('getStoredRateLimit')
+ ->willReturn(
+ $this->mockStoredRateLimit($rateLimit, 1000, new \DateTimeImmutable('+1 day'))
+ );
+
+ $this->storage->expects($this->once())->method('incrementHits');
+
+ $this->storage->expects($this->never())->method('storeRateLimit');
+
+ $oldController = $event->getController();
+ $this->limitRateListener->onKernelController($event);
+
+ $newController = $event->getController();
+ $this->assertNotSame($oldController, $newController);
+ /** @var Response $response */
+ $response = $newController();
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+ $this->assertSame('"Too Many Requests"', $response->getContent());
+
+ $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit'));
+ }
+
+ public function testItSetsABlockingResponseIfLimitIsExceeded(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())->method('getStoredRateLimit')->willReturn(
+ $this->mockStoredRateLimit($rateLimit, 1001, new \DateTimeImmutable('+1 day'))
+ );
+
+ $this->limitRateListener->onKernelController($event);
+ /** @var Response $response */
+ $response = ($event->getController())();
+ $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+ $this->assertSame('"Too Many Requests"', $response->getContent());
+ $this->assertSame('application/json', $response->headers->get('content-type'));
+ }
+
+ public function testTooManyRequestResponseHasCompleteDataIfDisplayHeadersIsEnable(): void
+ {
+ // Override $this->limitRateListener to displayHeaders
+ $this->limitRateListener = new LimitRateListener(
+ $this->storage = $this->createMock(RateLimitStorageInterface::class),
+ true
+ );
+
+ $event = $this->createEventWithRateLimitInRequest();
+ /** @var RateLimit $rateLimit */
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())->method('getStoredRateLimit')->willReturn(
+ $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1001, new \DateTimeImmutable('+1 day'))
+ );
+
+ $storedRateLimit
+ ->expects($this->once())
+ ->method('getLimitReachedOutput');
+
+ $this->limitRateListener->onKernelController($event);
+ /** @var Response $response */
+ $response = ($event->getController())();
+ $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+ $this->assertSame('application/json', $response->headers->get('content-type'));
+ }
+}
diff --git a/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php b/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php
new file mode 100644
index 0000000..e95420f
--- /dev/null
+++ b/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php
@@ -0,0 +1,47 @@
+limitRateListener = new LimitRateListener(
+ $this->storage = $this->createMock(ManuallyResetableRateLimitStorageInterface::class),
+ false
+ );
+ }
+
+ public function testItResetsAndStoresNewRateLimitIfCurrentOneIsOutdated(): void
+ {
+ $event = $this->createEventWithRateLimitInRequest();
+ $rateLimit = $event->getRequest()->attributes->get('_rate_limit');
+
+ $this->storage->expects($this->once())
+ ->method('getStoredRateLimit')
+ ->with($rateLimit)
+ ->willReturn(
+ $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1, new \DateTimeImmutable('1 day ago'))
+ );
+
+ $this->storage->expects($this->once())->method('resetRateLimit')->with($rateLimit);
+ $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit);
+
+ $this->storage->expects($this->never())->method('incrementHits');
+
+ $oldController = $event->getController();
+ $this->limitRateListener->onKernelController($event);
+ $this->assertSame($oldController, $event->getController());
+ $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit'));
+ }
+}
diff --git a/tests/EventListener/ReadRateLimitAnnotationListenerTest.php b/tests/EventListener/ReadRateLimitAnnotationListenerTest.php
new file mode 100644
index 0000000..d4e19e6
--- /dev/null
+++ b/tests/EventListener/ReadRateLimitAnnotationListenerTest.php
@@ -0,0 +1,209 @@
+ */
+ private $rateLimitModifiers;
+ private int $limitDefaultValue = 1000;
+ private int $periodDefaultValue = 60;
+ /** @var MockObject|ContainerInterface */
+ private $container;
+
+ public function createReadRateLimitAnnotationListerner(bool $defaultLimitByRouteValue = false): void
+ {
+ $this->readRateLimitAnnotationListener = new ReadRateLimitAnnotationListener(
+ $this->container = $this->createMock(ContainerInterface::class),
+ $this->annotationReader = $this->createMock(AnnotationReader::class),
+ $this->rateLimitModifiers = [
+ $this->createMock(RateLimitModifierInterface::class),
+ $this->createMock(RateLimitModifierInterface::class),
+ ],
+ $this->limitDefaultValue,
+ $this->periodDefaultValue,
+ $defaultLimitByRouteValue
+ );
+ }
+
+ public function testItDoesNotSetRateLimitIfNoAnnotationProvided(): void
+ {
+ $this->createReadRateLimitAnnotationListerner();
+ $event = $this->createEvent();
+
+ $this->container->expects($this->never())
+ ->method('has');
+
+ $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(null);
+
+ $this->rateLimitModifiers[0]->expects($this->never())->method('support');
+ $this->rateLimitModifiers[1]->expects($this->never())->method('support');
+
+ $this->readRateLimitAnnotationListener->onKernelController($event);
+ $this->assertFalse($event->getRequest()->attributes->has('_rate_limit'));
+ }
+
+ public function testItSetRateLimitIfNoAnnotationProvidedAndServiceAliasIsUsed(): void
+ {
+ $this->createReadRateLimitAnnotationListerner();
+
+ $this->container->expects($this->once())
+ ->method('get')
+ ->willReturn(new FakeInvokableClassWithDefaultRateLimit());
+
+ $event = $this->createEvent(null, true);
+
+ $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(null);
+
+ $this->rateLimitModifiers[0]->expects($this->never())->method('support');
+ $this->rateLimitModifiers[1]->expects($this->never())->method('support');
+
+ $this->readRateLimitAnnotationListener->onKernelController($event);
+ $this->assertFalse($event->getRequest()->attributes->has('_rate_limit'));
+ }
+
+ public function testItSetsRateLimitIfAnnotationProvidedWithDefaultValue(): void
+ {
+ $this->createReadRateLimitAnnotationListerner();
+ $request = $this->createMock(Request::class);
+ $request->attributes = new ParameterBag();
+ $event = $this->createEvent($request);
+
+ $this->container->expects($this->never())
+ ->method('has');
+
+ $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(new RateLimitAnnotation());
+
+ $this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true);
+ $rateLimit = new RateLimit($this->limitDefaultValue, $this->periodDefaultValue);
+ $this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit);
+
+ $this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false);
+ $this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit');
+
+ $this->readRateLimitAnnotationListener->onKernelController($event);
+ $this->assertTrue($event->getRequest()->attributes->has('_rate_limit'));
+
+ $this->assertEquals(
+ $rateLimit,
+ $event->getRequest()->attributes->get('_rate_limit')
+ );
+ }
+
+ /**
+ * @dataProvider rateLimitConfigurationDataProvider
+ */
+ public function testItSetsRateLimitIfAnnotationProvidedWithCustomValue(bool $isLimitByRouteEnbaled): void
+ {
+ $this->createReadRateLimitAnnotationListerner($isLimitByRouteEnbaled);
+
+ $request = $this->createMock(Request::class);
+ $request->attributes = new ParameterBag(['_route' => 'a-random-route']);
+ $event = $this->createEventWithAnnotation($request);
+
+ $this->container->expects($this->never())
+ ->method('has');
+
+ $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(new RateLimitAnnotation(['limit' => 10, 'period' => 5]));
+
+ $this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true);
+ if ($isLimitByRouteEnbaled) {
+ $rateLimit = new RateLimit(10, 5);
+ $rateLimit->varyHashOn('_route', 'a-random-route');
+ } else {
+ $rateLimit = new RateLimit($this->limitDefaultValue, $this->periodDefaultValue);
+ }
+ $this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit);
+
+ $this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false);
+ $this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit');
+
+ $this->readRateLimitAnnotationListener->onKernelController($event);
+ $this->assertTrue($event->getRequest()->attributes->has('_rate_limit'));
+
+ $this->assertEquals(
+ $rateLimit,
+ $event->getRequest()->attributes->get('_rate_limit')
+ );
+ }
+
+ /**
+ * @return \Generator>
+ */
+ public function rateLimitConfigurationDataProvider(): \Generator
+ {
+ yield 'rate_limit_by_route_is_disabled' => [
+ false,
+ ];
+
+ yield 'rate_limit_by_route_is_enabled' => [
+ true,
+ ];
+ }
+
+ protected function createEvent(Request $request = null, bool $useAlias = false): ControllerEvent
+ {
+ $request = $request ?? new Request();
+ $request->attributes->set('_controller', $useAlias ? 'fake.invokable.class:__invoke' : FakeInvokableClassWithDefaultRateLimit::class);
+
+ return new ControllerEvent(
+ $this->createMock(HttpKernelInterface::class),
+ new FakeInvokableClassWithDefaultRateLimit(),
+ $request ?? new Request(),
+ HttpKernelInterface::MASTER_REQUEST
+ );
+ }
+
+ public function createEventWithAnnotation(Request $request): ControllerEvent
+ {
+ $request->attributes->set('_controller', FakeClassWithRateLimit::class.'::action');
+
+ return new ControllerEvent(
+ $this->createMock(HttpKernelInterface::class),
+ [new FakeClassWithRateLimit(), 'action'],
+ $request,
+ HttpKernelInterface::MASTER_REQUEST
+ );
+ }
+}
+
+class FakeInvokableClassWithDefaultRateLimit
+{
+ /**
+ * @RateLimit()
+ */
+ public function __invoke(): void
+ {
+ }
+}
+
+class FakeClassWithRateLimit
+{
+ /**
+ * @RateLimite(
+ * limit=10,
+ * period=5
+ * )
+ */
+ public function action(): void
+ {
+ }
+}
diff --git a/tests/Model/RateLimitTest.php b/tests/Model/RateLimitTest.php
new file mode 100644
index 0000000..95926a9
--- /dev/null
+++ b/tests/Model/RateLimitTest.php
@@ -0,0 +1,65 @@
+expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Limit and period must be > 0');
+
+ new RateLimit(-10, 100);
+ }
+
+ public function testCreateARateLimitWithNegativePeriodThrowAnException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Limit and period must be > 0');
+
+ new RateLimit(10, -100);
+ }
+
+ public function testItThrowsExceptionWhenComputingDiscriminatorAndNoVaryProvided(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Cannot compute rate limit discriminator with an empty vary.');
+
+ $rateLimit = new RateLimit(1000, 60);
+ $rateLimit->getDiscriminator();
+ }
+
+ public function testItComputesDiscriminator(): void
+ {
+ $rateLimit = new RateLimit(1000, 60);
+ $rateLimit->varyHashOn('method', 'GET');
+ $this->assertSame('{"method":"GET"}', $rateLimit->getDiscriminator());
+
+ $rateLimit->varyHashOn('url', 'http://url');
+ $this->assertSame('{"method":"GET","url":"http:\/\/url"}', $rateLimit->getDiscriminator());
+ }
+
+ public function testItThrowsExceptionWhenComputingHashKeyAndNoVaryProvided(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Cannot compute rate limit discriminator with an empty vary.');
+
+ $rateLimit = new RateLimit(1000, 60);
+ $rateLimit->getHash();
+ }
+
+ public function testItComputesHash(): void
+ {
+ $rateLimit = new RateLimit(1000, 60);
+ $rateLimit->varyHashOn('method', 'GET');
+ $this->assertSame('aa33ceaf98a63b8bd52b3986a9ee06cb', $rateLimit->getHash());
+
+ $rateLimit->varyHashOn('url', 'http://url');
+ $this->assertSame('8288acbaf1221b58a2440c01448f5021', $rateLimit->getHash());
+ }
+}
diff --git a/tests/Model/StoredRateLimitTest.php b/tests/Model/StoredRateLimitTest.php
new file mode 100644
index 0000000..4bc4d0a
--- /dev/null
+++ b/tests/Model/StoredRateLimitTest.php
@@ -0,0 +1,48 @@
+assertFalse($storedRateLimit->isOutdated());
+
+ $storedRateLimit = new StoredRateLimit(new RateLimit(10, 10), 1, new \DateTimeImmutable('-1 day'));
+ $this->assertTrue($storedRateLimit->isOutdated());
+ }
+
+ public function testWithHitsCreateANewObject(): void
+ {
+ $storedRateLimit = new StoredRateLimit(new RateLimit(10, 10), 1, new \DateTimeImmutable('+1 day'));
+ $newStoredRateLimit = $storedRateLimit->withHits(5);
+
+ $this->assertNotSame($storedRateLimit, $newStoredRateLimit);
+ $this->assertSame(5, $newStoredRateLimit->getHits());
+ }
+
+ public function testItComputesLimitReachedMessage(): void
+ {
+ $storedRateLimit = new StoredRateLimit($rateLimit = new RateLimit(1000, 60), 1, new \DateTimeImmutable('2020-06-01'));
+ $rateLimit->varyHashOn('http_method', 'GET');
+ $rateLimit->varyHashOn('customer', 'customer-test');
+
+ $this->assertSame(
+ [
+ 'message' => 'Too many requests. Only 1000 calls allowed every 60 seconds.',
+ 'limit' => 1000,
+ 'period' => 60,
+ 'until' => '2020-06-01 00:00:00',
+ 'vary' => '{"http_method":"GET","customer":"customer-test"}',
+ ],
+ $storedRateLimit->getLimitReachedOutput()
+ );
+ }
+}
diff --git a/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php b/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php
new file mode 100644
index 0000000..72b8b89
--- /dev/null
+++ b/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php
@@ -0,0 +1,50 @@
+assertTrue($rateLimitModifier->support(new Request()));
+ }
+
+ /**
+ * @dataProvider itAddVaryOnHttpMethodProvider
+ */
+ public function testItAddVaryOnHttpMethod(string $method): void
+ {
+ $rateLimitModifier = new HttpMethodRateLimitModifier();
+
+ $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => $method]);
+ $rateLimitModifier->modifyRateLimit($request, $rateLimit = new RateLimit(10, 10));
+
+ $this->assertSame("{\"http_method\":\"$method\"}", $rateLimit->getDiscriminator());
+ }
+
+ /**
+ * @return array>
+ */
+ public function itAddVaryOnHttpMethodProvider(): array
+ {
+ return [
+ [
+ 'method' => 'GET',
+ ],
+ [
+ 'method' => 'POST',
+ ],
+ [
+ 'method' => 'PATCH',
+ ],
+ ];
+ }
+}
diff --git a/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php b/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php
new file mode 100644
index 0000000..faf68cf
--- /dev/null
+++ b/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php
@@ -0,0 +1,28 @@
+assertFalse($rateLimitModifier->support(new Request()));
+ $this->assertTrue($rateLimitModifier->support(new Request([], [], ['uid' => 'uid-test'])));
+ }
+
+ public function testItAddVaryOnUid(): void
+ {
+ $rateLimitModifier = new RequestAttributeRateLimitModifier('uid');
+ $request = new Request([], [], ['uid' => 'uid-test']);
+ $rateLimitModifier->modifyRateLimit($request, $rateLimit = new RateLimit(10, 10));
+ $this->assertSame('{"uid":"uid-test"}', $rateLimit->getDiscriminator());
+ }
+}