From 4d6c396f83a315629cc1aa9d11dd1f6062c344ab Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 18:52:32 +0400 Subject: [PATCH 1/8] [#290] Add first-class route rate limiting with global backend --- CHANGELOG.md | 9 ++ src/Middleware/MiddlewareManager.php | 38 +++++- .../Adapters/FileRateLimitAdapter.php | 71 +++++++++++ .../Adapters/RedisRateLimitAdapter.php | 71 +++++++++++ .../Contracts/RateLimitAdapterInterface.php | 24 ++++ src/RateLimit/Enums/ExceptionMessages.php | 24 ++++ src/RateLimit/Enums/RateLimitType.php | 33 ++++++ .../Exceptions/RateLimitException.php | 31 +++++ .../Factories/RateLimiterFactory.php | 89 ++++++++++++++ src/RateLimit/RateLimitMiddleware.php | 69 +++++++++++ src/RateLimit/RateLimiter.php | 70 +++++++++++ src/Router/Enums/ExceptionMessages.php | 2 + src/Router/Exceptions/RouteException.php | 8 ++ src/Router/Route.php | 29 +++++ src/Router/RouteBuilder.php | 46 ++++++-- tests/Helpers/InMemoryPsrCache.php | 70 +++++++++++ .../Unit/Middleware/MiddlewareManagerTest.php | 20 ++++ .../Adapters/FileRateLimitAdapterTest.php | 24 ++++ .../Adapters/RedisRateLimitAdapterTest.php | 23 ++++ .../Exceptions/RateLimitExceptionTest.php | 20 ++++ .../Factories/RateLimiterFactoryTest.php | 64 ++++++++++ .../RateLimit/RateLimitMiddlewareTest.php | 74 ++++++++++++ tests/Unit/RateLimit/RateLimiterTest.php | 110 ++++++++++++++++++ .../Router/Exceptions/RouteExceptionTest.php | 2 + tests/Unit/Router/RouteBuilderTest.php | 54 +++++++++ tests/Unit/Router/RouteTest.php | 35 ++++++ tests/_root/shared/config/rate_limit.php | 5 + 27 files changed, 1104 insertions(+), 11 deletions(-) create mode 100644 src/RateLimit/Adapters/FileRateLimitAdapter.php create mode 100644 src/RateLimit/Adapters/RedisRateLimitAdapter.php create mode 100644 src/RateLimit/Contracts/RateLimitAdapterInterface.php create mode 100644 src/RateLimit/Enums/ExceptionMessages.php create mode 100644 src/RateLimit/Enums/RateLimitType.php create mode 100644 src/RateLimit/Exceptions/RateLimitException.php create mode 100644 src/RateLimit/Factories/RateLimiterFactory.php create mode 100644 src/RateLimit/RateLimitMiddleware.php create mode 100644 src/RateLimit/RateLimiter.php create mode 100644 tests/Helpers/InMemoryPsrCache.php create mode 100644 tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php create mode 100644 tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php create mode 100644 tests/Unit/RateLimit/Exceptions/RateLimitExceptionTest.php create mode 100644 tests/Unit/RateLimit/Factories/RateLimiterFactoryTest.php create mode 100644 tests/Unit/RateLimit/RateLimitMiddlewareTest.php create mode 100644 tests/Unit/RateLimit/RateLimiterTest.php create mode 100644 tests/_root/shared/config/rate_limit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 809f26a90..907229371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ - Models are hydrated only on fetch operations, not implicitly via getters - `DbModel::save()` now automatically applies timestamps when the model uses the `HasTimestamps` trait - `SoftDeletes` now uses model timestamp format when available (datetime/unix) for `deleted_at` +- Routing DSL now supports first-class route rate limiting via `->rateLimit(int $limit, int $interval)`. +- Middleware execution pipeline now includes a framework-level middleware stage before module middleware resolution, used for route rate limit enforcement. ### Fixed - PHP 8.1 compatibility: Fixed null parameter deprecations in `explode()`, `parse_url()`, and `str_replace()` @@ -107,6 +109,13 @@ - Automatically sets `updated_at` on insert and update - Supports custom timestamp column names via model constants (`CREATED_AT`, `UPDATED_AT`) - Supports datetime and unix timestamp formats via `TIMESTAMP_TYPE` +- **Rate Limiting**: + - Added `RateLimit` package with contract-first architecture (`RateLimitAdapterInterface`, `RateLimiter`, `RateLimiterFactory`) + - Added file and redis rate limit adapters + - Added `RateLimitMiddleware` for centralized `429 Too Many Requests` enforcement + - Added rate limit response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`) + - Added route-level metadata support on `Route` and `RouteBuilder` for rate limiting + - Added unit test coverage for router integration, middleware flow, adapters, factory resolution, and exception behavior ### Removed - Support for PHP 7.3 and earlier versions diff --git a/src/Middleware/MiddlewareManager.php b/src/Middleware/MiddlewareManager.php index 8d7052ed7..4090781ad 100644 --- a/src/Middleware/MiddlewareManager.php +++ b/src/Middleware/MiddlewareManager.php @@ -17,11 +17,12 @@ namespace Quantum\Middleware; use Quantum\Middleware\Exceptions\MiddlewareException; +use Quantum\RateLimit\RateLimitMiddleware; use Quantum\App\Exceptions\BaseException; use Quantum\Router\MatchedRoute; +use Quantum\Router\Route; use Quantum\Http\Response; use Quantum\Http\Request; -use ReflectionException; use Closure; /** @@ -41,19 +42,50 @@ class MiddlewareManager */ private ?string $module; + private Route $route; + + private bool $hasRateLimit; + public function __construct(MatchedRoute $matchedRoute) { $route = $matchedRoute->getRoute(); + $this->route = $route; $this->middlewares = array_values($route->getMiddlewares() ?? []); $this->module = $route->getModule(); + $this->hasRateLimit = $route->getRateLimit() !== null; } /** * Apply Middlewares. - * @throws MiddlewareException|BaseException|ReflectionException + * @throws MiddlewareException|BaseException */ public function applyMiddlewares(Request $request, Closure $terminal): Response + { + return $this->applyFrameworkMiddlewares( + $request, + fn (Request $request): Response => $this->applyModuleMiddlewares($request, $terminal) + ); + } + + /** + * Apply framework-level middleware stage. + */ + private function applyFrameworkMiddlewares(Request $request, Closure $next): Response + { + if (!$this->hasRateLimit) { + return $next($request); + } + + $middleware = new RateLimitMiddleware($this->route); + return $middleware->apply($request, $next); + } + + /** + * Apply module middleware stage. + * @throws MiddlewareException|BaseException + */ + private function applyModuleMiddlewares(Request $request, Closure $terminal): Response { if (!current($this->middlewares)) { return $terminal($request); @@ -62,7 +94,7 @@ public function applyMiddlewares(Request $request, Closure $terminal): Response $currentMiddleware = $this->getMiddleware($request); next($this->middlewares); - return $currentMiddleware->apply($request, fn (Request $request): Response => $this->applyMiddlewares($request, $terminal)); + return $currentMiddleware->apply($request, fn (Request $request): Response => $this->applyModuleMiddlewares($request, $terminal)); } /** diff --git a/src/RateLimit/Adapters/FileRateLimitAdapter.php b/src/RateLimit/Adapters/FileRateLimitAdapter.php new file mode 100644 index 000000000..616e65ad9 --- /dev/null +++ b/src/RateLimit/Adapters/FileRateLimitAdapter.php @@ -0,0 +1,71 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Adapters; + +use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; +use Quantum\Cache\Cache; + +class FileRateLimitAdapter implements RateLimitAdapterInterface +{ + private Cache $cache; + + private int $resetInterval; + + public function __construct(?Cache $cache = null, int $resetInterval = 60) + { + $this->cache = $cache ?? cache('file'); + $this->resetInterval = $resetInterval; + } + + public function hit(string $key, int $limit, int $interval): bool + { + $now = time(); + $data = $this->cache->get($key); + + if (!is_array($data) || !isset($data['count'], $data['reset_at']) || $now >= (int) $data['reset_at']) { + $count = 0; + $resetAt = $now + $interval; + } else { + $count = (int) $data['count']; + $resetAt = (int) $data['reset_at']; + } + + $count++; + + $ttl = max(1, $resetAt - $now); + + $this->cache->set($key, [ + 'count' => $count, + 'reset_at' => $resetAt, + ], $ttl); + + return $count <= $limit; + } + + public function reset(string $key, int $count = 0): void + { + if ($count <= 0) { + $this->cache->delete($key); + return; + } + + $this->cache->set($key, [ + 'count' => $count, + 'reset_at' => time() + $this->resetInterval, + ], $this->resetInterval); + } +} diff --git a/src/RateLimit/Adapters/RedisRateLimitAdapter.php b/src/RateLimit/Adapters/RedisRateLimitAdapter.php new file mode 100644 index 000000000..77a0cda60 --- /dev/null +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -0,0 +1,71 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Adapters; + +use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; +use Quantum\Cache\Cache; + +class RedisRateLimitAdapter implements RateLimitAdapterInterface +{ + private Cache $cache; + + private int $resetInterval; + + public function __construct(?Cache $cache = null, int $resetInterval = 60) + { + $this->cache = $cache ?? cache('redis'); + $this->resetInterval = $resetInterval; + } + + public function hit(string $key, int $limit, int $interval): bool + { + $now = time(); + $data = $this->cache->get($key); + + if (!is_array($data) || !isset($data['count'], $data['reset_at']) || $now >= (int) $data['reset_at']) { + $count = 0; + $resetAt = $now + $interval; + } else { + $count = (int) $data['count']; + $resetAt = (int) $data['reset_at']; + } + + $count++; + + $ttl = max(1, $resetAt - $now); + + $this->cache->set($key, [ + 'count' => $count, + 'reset_at' => $resetAt, + ], $ttl); + + return $count <= $limit; + } + + public function reset(string $key, int $count = 0): void + { + if ($count <= 0) { + $this->cache->delete($key); + return; + } + + $this->cache->set($key, [ + 'count' => $count, + 'reset_at' => time() + $this->resetInterval, + ], $this->resetInterval); + } +} diff --git a/src/RateLimit/Contracts/RateLimitAdapterInterface.php b/src/RateLimit/Contracts/RateLimitAdapterInterface.php new file mode 100644 index 000000000..47fae2f7a --- /dev/null +++ b/src/RateLimit/Contracts/RateLimitAdapterInterface.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Contracts; + +interface RateLimitAdapterInterface +{ + public function hit(string $key, int $limit, int $interval): bool; + + public function reset(string $key, int $count = 0): void; +} diff --git a/src/RateLimit/Enums/ExceptionMessages.php b/src/RateLimit/Enums/ExceptionMessages.php new file mode 100644 index 000000000..b0f28bebe --- /dev/null +++ b/src/RateLimit/Enums/ExceptionMessages.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Enums; + +use Quantum\App\Enums\ExceptionMessages as BaseExceptionMessages; + +final class ExceptionMessages extends BaseExceptionMessages +{ + public const ADAPTER_NOT_SUPPORTED = 'Rate limit adapter `{%1}` is not supported.'; +} diff --git a/src/RateLimit/Enums/RateLimitType.php b/src/RateLimit/Enums/RateLimitType.php new file mode 100644 index 000000000..1846223cf --- /dev/null +++ b/src/RateLimit/Enums/RateLimitType.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Enums; + +/** + * Class RateLimitType + * @package Quantum\RateLimit + * @codeCoverageIgnore + */ +final class RateLimitType +{ + public const FILE = 'file'; + + public const REDIS = 'redis'; + + private function __construct() + { + } +} diff --git a/src/RateLimit/Exceptions/RateLimitException.php b/src/RateLimit/Exceptions/RateLimitException.php new file mode 100644 index 000000000..1b4b4499e --- /dev/null +++ b/src/RateLimit/Exceptions/RateLimitException.php @@ -0,0 +1,31 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Exceptions; + +use Quantum\RateLimit\Enums\ExceptionMessages; +use Quantum\App\Exceptions\BaseException; + +class RateLimitException extends BaseException +{ + public static function adapterNotSupported(string $adapter): self + { + return new self( + _message(ExceptionMessages::ADAPTER_NOT_SUPPORTED, [$adapter]), + E_ERROR + ); + } +} diff --git a/src/RateLimit/Factories/RateLimiterFactory.php b/src/RateLimit/Factories/RateLimiterFactory.php new file mode 100644 index 000000000..00b5ee1be --- /dev/null +++ b/src/RateLimit/Factories/RateLimiterFactory.php @@ -0,0 +1,89 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Factories; + +use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; +use Quantum\RateLimit\Adapters\RedisRateLimitAdapter; +use Quantum\RateLimit\Adapters\FileRateLimitAdapter; +use Quantum\RateLimit\Exceptions\RateLimitException; +use Quantum\Config\Exceptions\ConfigException; +use Quantum\RateLimit\Enums\RateLimitType; +use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; +use Quantum\RateLimit\RateLimiter; +use Quantum\Loader\Setup; +use ReflectionException; +use Quantum\Di\Di; + +class RateLimiterFactory +{ + public const ADAPTERS = [ + RateLimitType::FILE => FileRateLimitAdapter::class, + RateLimitType::REDIS => RedisRateLimitAdapter::class, + ]; + + /** + * @var array + */ + private array $instances = []; + + /** + * @throws ConfigException|BaseException|DiException|ReflectionException + */ + public static function get(?string $adapter = null): RateLimiter + { + if (!Di::isRegistered(self::class)) { + Di::register(self::class); + } + + return Di::get(self::class)->resolve($adapter); + } + + /** + * @throws ConfigException|BaseException|DiException|ReflectionException + */ + public function resolve(?string $adapter = null): RateLimiter + { + if (!config()->has('rate_limit')) { + config()->import(new Setup('config', 'rate_limit')); + } + + $adapter ??= (string) config()->get('rate_limit.default', RateLimitType::FILE); + + if (!isset($this->instances[$adapter])) { + $this->instances[$adapter] = new RateLimiter( + $this->createAdapter($adapter) + ); + } + + return $this->instances[$adapter]; + } + + /** + * @throws BaseException + */ + private function createAdapter(string $adapter): RateLimitAdapterInterface + { + $class = self::ADAPTERS[$adapter] ?? null; + + if ($class === null) { + throw RateLimitException::adapterNotSupported($adapter); + } + + return new $class(); + } +} diff --git a/src/RateLimit/RateLimitMiddleware.php b/src/RateLimit/RateLimitMiddleware.php new file mode 100644 index 000000000..64e7de2ab --- /dev/null +++ b/src/RateLimit/RateLimitMiddleware.php @@ -0,0 +1,69 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit; + +use Quantum\RateLimit\Factories\RateLimiterFactory; +use Quantum\Http\Enums\StatusCode; +use Quantum\Middleware\Middleware; +use Quantum\Router\Route; +use Quantum\Http\Response; +use Quantum\Http\Request; +use Closure; + +class RateLimitMiddleware extends Middleware +{ + private Route $route; + + public function __construct(Route $route) + { + $this->route = $route; + } + + public function apply(Request $request, Closure $next): Response + { + $settings = $this->route->getRateLimit(); + + if ($settings === null) { + return $next($request); + } + + $limit = (int) ($settings['limit'] ?? 0); + $interval = (int) ($settings['interval'] ?? 0); + + if ($limit <= 0 || $interval <= 0) { + return $next($request); + } + + $limiter = RateLimiterFactory::get(); + $method = $request->getMethod() ?? 'GET'; + $pattern = $this->route->getPattern(); + $ip = get_user_ip(); + + $allowed = $limiter->hit($method, $pattern, $ip, $limit, $interval); + + if (!$allowed) { + return response() + ->setHeader('X-RateLimit-Limit', (string) $limit) + ->setHeader('X-RateLimit-Remaining', '0') + ->setHeader('Retry-After', (string) $interval) + ->json(['message' => 'Too Many Requests'], StatusCode::TOO_MANY_REQUESTS); + } + + return $next($request) + ->setHeader('X-RateLimit-Limit', (string) $limit); + } +} diff --git a/src/RateLimit/RateLimiter.php b/src/RateLimit/RateLimiter.php new file mode 100644 index 000000000..7578e5932 --- /dev/null +++ b/src/RateLimit/RateLimiter.php @@ -0,0 +1,70 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit; + +use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; + +class RateLimiter +{ + private RateLimitAdapterInterface $adapter; + + public function __construct(RateLimitAdapterInterface $adapter) + { + $this->adapter = $adapter; + } + + public function getAdapter(): RateLimitAdapterInterface + { + return $this->adapter; + } + + public function hit( + string $method, + string $routePattern, + ?string $ip, + int $limit, + int $interval + ): bool { + return $this->adapter->hit( + $this->buildKey($method, $routePattern, $ip), + $limit, + $interval + ); + } + + public function reset(string $method, string $routePattern, ?string $ip, int $count = 0): void + { + $this->adapter->reset($this->buildKey($method, $routePattern, $ip), $count); + } + + public function buildKey(string $method, string $routePattern, ?string $ip): string + { + $normalizedMethod = strtoupper(trim($method)); + $normalizedRoute = trim($routePattern); + $normalizedIp = trim((string) $ip); + + if ($normalizedRoute === '') { + $normalizedRoute = '/'; + } + + if ($normalizedIp === '') { + $normalizedIp = '0.0.0.0'; + } + + return $normalizedMethod . ':' . $normalizedRoute . ':' . $normalizedIp; + } +} diff --git a/src/Router/Enums/ExceptionMessages.php b/src/Router/Enums/ExceptionMessages.php index 63136c6b4..19880f1ca 100644 --- a/src/Router/Enums/ExceptionMessages.php +++ b/src/Router/Enums/ExceptionMessages.php @@ -42,6 +42,8 @@ final class ExceptionMessages extends BaseExceptionMessages public const CACHEABLE_OUTSIDE_ROUTE = 'cacheable() must be called inside a group or after a route definition'; + public const RATE_LIMIT_OUTSIDE_ROUTE = 'rateLimit() must be called inside a group or after a route definition'; + public const CLOSURE_ROUTE_MISSING_HANDLER = 'Closure route is missing its closure handler'; public const ACTION_NOT_FOUND_ON_CONTROLLER = 'Action `{%1}` not found on controller `{%2}`'; diff --git a/src/Router/Exceptions/RouteException.php b/src/Router/Exceptions/RouteException.php index e893a41b5..9167feedf 100644 --- a/src/Router/Exceptions/RouteException.php +++ b/src/Router/Exceptions/RouteException.php @@ -97,6 +97,14 @@ public static function cacheableOutsideRoute(): self ); } + public static function rateLimitOutsideRoute(): self + { + return new self( + ExceptionMessages::RATE_LIMIT_OUTSIDE_ROUTE, + E_WARNING + ); + } + public static function closureHandlerMissing(): self { return new self( diff --git a/src/Router/Route.php b/src/Router/Route.php index feff8681f..b2305789a 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -69,6 +69,11 @@ final class Route */ protected ?array $cache = null; + /** + * @var array|null + */ + protected ?array $rateLimit = null; + /** * @var string|null */ @@ -143,6 +148,29 @@ public function getCache(): ?array return $this->cache; } + /** + * Configure rate limiting settings for this route. + * @return $this + */ + public function rateLimit(int $limit, int $interval): self + { + $this->rateLimit = [ + 'limit' => $limit, + 'interval' => $interval, + ]; + + return $this; + } + + /** + * Return rate limit configuration for this route. + * @return array|null + */ + public function getRateLimit(): ?array + { + return $this->rateLimit; + } + /** * Store compiled regex pattern for this route. * @return $this @@ -332,6 +360,7 @@ public function toArray(): array 'group' => $this->group, 'prefix' => $this->prefix, 'cache' => $this->cache, + 'rateLimit' => $this->rateLimit, ]; } } diff --git a/src/Router/RouteBuilder.php b/src/Router/RouteBuilder.php index 1c18e8187..3a44cd34c 100644 --- a/src/Router/RouteBuilder.php +++ b/src/Router/RouteBuilder.php @@ -17,8 +17,6 @@ namespace Quantum\Router; use Quantum\Router\Exceptions\RouteException; -use Quantum\Di\Exceptions\DiException; -use ReflectionException; use Closure; /** @@ -96,7 +94,7 @@ public function build(array $moduleRouteClosures, array $moduleConfigs): RouteCo * Define a route with multiple HTTP methods. * @param callable|string $handler * @param string|null $action - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ public function add(string $path, string $methods, $handler, string $action = null): self { @@ -112,7 +110,7 @@ public function add(string $path, string $methods, $handler, string $action = nu * Define a GET route. * @param callable|string $handler * @param string|null $action - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ public function get(string $path, $handler, string $action = null): self { @@ -123,7 +121,7 @@ public function get(string $path, $handler, string $action = null): self * Define a POST route. * @param callable|string $handler * @param string|null $action - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ public function post(string $path, $handler, string $action = null): self { @@ -134,7 +132,7 @@ public function post(string $path, $handler, string $action = null): self * Define a PUT route. * @param callable|string $handler * @param string|null $action - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ public function put(string $path, $handler, string $action = null): self { @@ -144,7 +142,7 @@ public function put(string $path, $handler, string $action = null): self /** * Define a DELETE route. * @param callable|string $handler - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ public function delete(string $path, $handler, string $action = null): self { @@ -277,11 +275,43 @@ public function cacheable(bool $enabled, ?int $ttl = null): self throw RouteException::cacheableOutsideRoute(); } + /** + * Configure rate limiting for the current route or group. + * @return $this + * @throws RouteException + */ + public function rateLimit(int $limit, int $interval): self + { + if ($this->inGroup) { + foreach ($this->groupRoutes as $route) { + $route->rateLimit($limit, $interval); + } + + return $this; + } + + if ($this->lastGroupRoutes !== null) { + foreach ($this->lastGroupRoutes as $route) { + $route->rateLimit($limit, $interval); + } + + $this->lastGroupRoutes = null; + return $this; + } + + if ($this->currentRoute !== null) { + $this->currentRoute->rateLimit($limit, $interval); + return $this; + } + + throw RouteException::rateLimitOutsideRoute(); + } + /** * Create and register a Route instance. * @param array $methods * @param callable|string $handler - * @throws RouteException|DiException|ReflectionException + * @throws RouteException */ private function addRoute( array $methods, diff --git a/tests/Helpers/InMemoryPsrCache.php b/tests/Helpers/InMemoryPsrCache.php new file mode 100644 index 000000000..b2094dd8b --- /dev/null +++ b/tests/Helpers/InMemoryPsrCache.php @@ -0,0 +1,70 @@ + + */ + private array $items = []; + + public function get($key, $default = null) + { + return array_key_exists($key, $this->items) ? $this->items[$key] : $default; + } + + public function set($key, $value, $ttl = null): bool + { + $this->items[$key] = $value; + return true; + } + + public function delete($key): bool + { + unset($this->items[$key]); + return true; + } + + public function clear(): bool + { + $this->items = []; + return true; + } + + public function getMultiple($keys, $default = null) + { + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + + return $result; + } + + public function setMultiple($values, $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple($keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + + return true; + } + + public function has($key): bool + { + return array_key_exists($key, $this->items); + } +} diff --git a/tests/Unit/Middleware/MiddlewareManagerTest.php b/tests/Unit/Middleware/MiddlewareManagerTest.php index 6ae237375..4bd815e97 100644 --- a/tests/Unit/Middleware/MiddlewareManagerTest.php +++ b/tests/Unit/Middleware/MiddlewareManagerTest.php @@ -134,5 +134,25 @@ public function testMiddlewareManagerThrowsWhenNotInstanceOfMiddleware(): void $manager = new MiddlewareManager($matchedRoute); $manager->applyMiddlewares(request(), fn (Request $request): Response => response()->json([])); } + + public function testMiddlewareManagerPrependsFrameworkRateLimitMiddleware(): void + { + $route = new Route(['GET'], '/test-route', 'TestController', 'index'); + $route->module('Test'); + $route->rateLimit(100, 60); + + $matchedRoute = new MatchedRoute($route, []); + + $manager = new MiddlewareManager($matchedRoute); + $result = $manager->applyMiddlewares( + request(), + fn (Request $request): Response => response()->json(['status' => 'terminal']) + ); + + $this->assertSame(response(), $result); + $this->assertSame('100', $result->getHeader('X-RateLimit-Limit')); + $this->assertSame('{"status":"terminal"}', $result->getContent()); + } + } } diff --git a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php new file mode 100644 index 000000000..e598544cf --- /dev/null +++ b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php @@ -0,0 +1,24 @@ +assertTrue($adapter->hit('k1', 2, 60)); + $this->assertTrue($adapter->hit('k1', 2, 60)); + $this->assertFalse($adapter->hit('k1', 2, 60)); + + $adapter->reset('k1'); + $this->assertTrue($adapter->hit('k1', 2, 60)); + } +} diff --git a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php new file mode 100644 index 000000000..414e57878 --- /dev/null +++ b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php @@ -0,0 +1,23 @@ +assertTrue($adapter->hit('k2', 1, 60)); + $this->assertFalse($adapter->hit('k2', 1, 60)); + + $adapter->reset('k2', 1); + $this->assertFalse($adapter->hit('k2', 1, 60)); + } +} diff --git a/tests/Unit/RateLimit/Exceptions/RateLimitExceptionTest.php b/tests/Unit/RateLimit/Exceptions/RateLimitExceptionTest.php new file mode 100644 index 000000000..f25a6eee6 --- /dev/null +++ b/tests/Unit/RateLimit/Exceptions/RateLimitExceptionTest.php @@ -0,0 +1,20 @@ +assertSame( + 'Rate limit adapter `invalid` is not supported.', + $exception->getMessage() + ); + $this->assertSame(E_ERROR, $exception->getCode()); + } +} diff --git a/tests/Unit/RateLimit/Factories/RateLimiterFactoryTest.php b/tests/Unit/RateLimit/Factories/RateLimiterFactoryTest.php new file mode 100644 index 000000000..731092861 --- /dev/null +++ b/tests/Unit/RateLimit/Factories/RateLimiterFactoryTest.php @@ -0,0 +1,64 @@ +resetRateLimiterFactory(); + } + + public function testRateLimiterFactoryReturnsRateLimiter(): void + { + $limiter = RateLimiterFactory::get(); + $this->assertInstanceOf(RateLimiter::class, $limiter); + } + + public function testRateLimiterFactoryDefaultAdapterIsFile(): void + { + $limiter = RateLimiterFactory::get(); + $this->assertInstanceOf(FileRateLimitAdapter::class, $limiter->getAdapter()); + } + + public function testRateLimiterFactoryRedisAdapter(): void + { + $limiter = RateLimiterFactory::get(RateLimitType::REDIS); + $this->assertInstanceOf(RedisRateLimitAdapter::class, $limiter->getAdapter()); + } + + public function testRateLimiterFactoryThrowsForInvalidAdapter(): void + { + $this->expectException(RateLimitException::class); + $this->expectExceptionMessage('Rate limit adapter `invalid` is not supported.'); + + RateLimiterFactory::get('invalid'); + } + + public function testRateLimiterFactoryReturnsSameInstancePerAdapter(): void + { + $limiter1 = RateLimiterFactory::get(RateLimitType::FILE); + $limiter2 = RateLimiterFactory::get(RateLimitType::FILE); + $this->assertSame($limiter1, $limiter2); + } + + private function resetRateLimiterFactory(): void + { + if (!Di::isRegistered(RateLimiterFactory::class)) { + Di::register(RateLimiterFactory::class); + } + + $factory = Di::get(RateLimiterFactory::class); + $this->setPrivateProperty($factory, 'instances', []); + } +} diff --git a/tests/Unit/RateLimit/RateLimitMiddlewareTest.php b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php new file mode 100644 index 000000000..f4aa3f0e6 --- /dev/null +++ b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php @@ -0,0 +1,74 @@ +setPrivateProperty($factory, 'instances', [ + 'file' => new RateLimiter(new ToggleAdapter()), + ]); + } + + public function testRateLimitMiddlewareBlocksWith429AndHeaders(): void + { + $route = new Route(['GET'], '/posts', 'PostController', 'index'); + $route->rateLimit(1, 60); + + $middleware = new RateLimitMiddleware($route); + + $first = $middleware->apply( + request(), + fn (Request $request): Response => response()->json(['status' => 'ok']) + ); + + $this->assertSame(200, $first->getStatusCode()); + $this->assertSame('1', $first->getHeader('X-RateLimit-Limit')); + + response()->flush(); + + $second = $middleware->apply( + request(), + fn (Request $request): Response => response()->json(['status' => 'ok']) + ); + + $this->assertSame(429, $second->getStatusCode()); + $this->assertSame('1', $second->getHeader('X-RateLimit-Limit')); + $this->assertSame('0', $second->getHeader('X-RateLimit-Remaining')); + $this->assertSame('60', $second->getHeader('Retry-After')); + $this->assertSame('{"message":"Too Many Requests"}', $second->getContent()); + } +} + +class ToggleAdapter implements RateLimitAdapterInterface +{ + private int $calls = 0; + + public function hit(string $key, int $limit, int $interval): bool + { + $this->calls++; + return $this->calls === 1; + } + + public function reset(string $key, int $count = 0): void + { + } +} diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php new file mode 100644 index 000000000..3d2c5abcb --- /dev/null +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -0,0 +1,110 @@ +assertSame( + 'POST:/api/posts:127.0.0.1', + $limiter->buildKey('post', ' /api/posts ', '127.0.0.1') + ); + } + + public function testRateLimiterUsesFallbackKeyPartsWhenEmpty(): void + { + $adapter = new class () implements RateLimitAdapterInterface { + public function hit(string $key, int $limit, int $interval): bool + { + return true; + } + + public function reset(string $key, int $count = 0): void + { + } + }; + + $limiter = new RateLimiter($adapter); + + $this->assertSame('GET:/:0.0.0.0', $limiter->buildKey('get', '', null)); + } + + public function testRateLimiterDelegatesHitToAdapter(): void + { + $calls = []; + + $adapter = new class ($calls) implements RateLimitAdapterInterface { + private array $calls; + + public function __construct(array &$calls) + { + $this->calls = &$calls; + } + + public function hit(string $key, int $limit, int $interval): bool + { + $this->calls[] = [$key, $limit, $interval]; + return false; + } + + public function reset(string $key, int $count = 0): void + { + } + }; + + $limiter = new RateLimiter($adapter); + + $allowed = $limiter->hit('get', '/status', '10.0.0.1', 100, 60); + + $this->assertFalse($allowed); + $this->assertSame([['GET:/status:10.0.0.1', 100, 60]], $calls); + } + + public function testRateLimiterDelegatesResetToAdapter(): void + { + $calls = []; + + $adapter = new class ($calls) implements RateLimitAdapterInterface { + private array $calls; + + public function __construct(array &$calls) + { + $this->calls = &$calls; + } + + public function hit(string $key, int $limit, int $interval): bool + { + return true; + } + + public function reset(string $key, int $count = 0): void + { + $this->calls[] = [$key, $count]; + } + }; + + $limiter = new RateLimiter($adapter); + + $limiter->reset('post', '/api/posts', '10.0.0.2', 3); + + $this->assertSame([['POST:/api/posts:10.0.0.2', 3]], $calls); + } +} diff --git a/tests/Unit/Router/Exceptions/RouteExceptionTest.php b/tests/Unit/Router/Exceptions/RouteExceptionTest.php index c4e9cc56f..46feb9dcc 100644 --- a/tests/Unit/Router/Exceptions/RouteExceptionTest.php +++ b/tests/Unit/Router/Exceptions/RouteExceptionTest.php @@ -23,6 +23,8 @@ public function testCoreFactories(): void $this->assertSame(E_ERROR, RouteException::controllerWithoutModule()->getCode()); $this->assertSame('cacheable() must be called inside a group or after a route definition', RouteException::cacheableOutsideRoute()->getMessage()); $this->assertSame(E_WARNING, RouteException::cacheableOutsideRoute()->getCode()); + $this->assertSame('rateLimit() must be called inside a group or after a route definition', RouteException::rateLimitOutsideRoute()->getMessage()); + $this->assertSame(E_WARNING, RouteException::rateLimitOutsideRoute()->getCode()); $this->assertSame('Closure route is missing its closure handler', RouteException::closureHandlerMissing()->getMessage()); $this->assertSame(E_ERROR, RouteException::closureHandlerMissing()->getCode()); $this->assertSame('Route is not a closure', RouteException::notClosure()->getMessage()); diff --git a/tests/Unit/Router/RouteBuilderTest.php b/tests/Unit/Router/RouteBuilderTest.php index e272e05e0..e64b82de3 100644 --- a/tests/Unit/Router/RouteBuilderTest.php +++ b/tests/Unit/Router/RouteBuilderTest.php @@ -376,6 +376,49 @@ public function testRouteBuilderCacheableAppliesToGroupRoutes(): void } } + public function testRouteBuilderRateLimitAppliesToSingleRoute(): void + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route): void { + $route->get('api/posts', 'PostController', 'index') + ->rateLimit(100, 60); + }, + ], + [] + ); + + $rateLimit = $routes->all()[0]->getRateLimit(); + + $this->assertSame(100, $rateLimit['limit']); + $this->assertSame(60, $rateLimit['interval']); + } + + public function testRouteBuilderRateLimitAppliesToGroupRoutes(): void + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route): void { + $route->group('api', function (RouteBuilder $route): void { + $route->get('a', 'AController', 'a'); + $route->get('b', 'BController', 'b'); + })->rateLimit(50, 30); + }, + ], + [] + ); + + foreach ($routes->all() as $item) { + $rateLimit = $item->getRateLimit(); + $this->assertSame(50, $rateLimit['limit']); + $this->assertSame(30, $rateLimit['interval']); + } + } + public function testRouteBuilderAddRouteRequiresControllerAndAction(): void { $this->expectException(RouteException::class); @@ -490,6 +533,17 @@ public function testRouteBuilderCacheableMustFollowRouteOrGroup(): void ], []); } + public function testRouteBuilderRateLimitMustFollowRouteOrGroup(): void + { + $this->expectException(RouteException::class); + + (new RouteBuilder())->build([ + 'Web' => function (RouteBuilder $route): void { + $route->rateLimit(100, 60); + }, + ], []); + } + public function testRouteBuilderResolvePathTrimsSlashesCorrectly(): void { $routes = (new RouteBuilder())->build( diff --git a/tests/Unit/Router/RouteTest.php b/tests/Unit/Router/RouteTest.php index 811719185..0abe6c1c8 100644 --- a/tests/Unit/Router/RouteTest.php +++ b/tests/Unit/Router/RouteTest.php @@ -125,6 +125,23 @@ public function testRouteCompiledPatternCanBeStored(): void $this->assertSame('^users$', $route->getCompiledPattern()); } + public function testRouteRateLimitConfigurationIsStored(): void + { + $route = new Route( + ['GET'], + 'posts', + 'PostController', + 'indexAction' + ); + + $route->rateLimit(100, 60); + + $this->assertSame( + ['limit' => 100, 'interval' => 60], + $route->getRateLimit() + ); + } + public function testRouteMiddlewareStackingOrder(): void { $route = new Route( @@ -167,5 +184,23 @@ public function testRouteToArrayExportsRouteState(): void $this->assertSame('listAction', $data['action']); $this->assertSame(['enabled' => true, 'ttl' => 60], $data['cache']); + + $this->assertNull($data['rateLimit']); + } + + public function testRouteToArrayIncludesRateLimit(): void + { + $route = new Route( + ['GET'], + 'posts', + 'PostController', + 'indexAction' + ); + + $route->rateLimit(50, 30); + + $data = $route->toArray(); + + $this->assertSame(['limit' => 50, 'interval' => 30], $data['rateLimit']); } } diff --git a/tests/_root/shared/config/rate_limit.php b/tests/_root/shared/config/rate_limit.php new file mode 100644 index 000000000..f6cddd9bf --- /dev/null +++ b/tests/_root/shared/config/rate_limit.php @@ -0,0 +1,5 @@ + 'file', +]; From 4b839c418d59df4d6d90f0af4a5925a45bdc2337 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 19:28:55 +0400 Subject: [PATCH 2/8] [#290] Return dynamic Retry-After for rate limit windows --- .../Adapters/FileRateLimitAdapter.php | 11 +++++ .../Adapters/RedisRateLimitAdapter.php | 11 +++++ .../Contracts/RateLimitAdapterInterface.php | 2 + src/RateLimit/RateLimitMiddleware.php | 7 ++- src/RateLimit/RateLimiter.php | 7 +++ .../RateLimit/RateLimitMiddlewareTest.php | 7 ++- tests/Unit/RateLimit/RateLimiterTest.php | 43 +++++++++++++++++++ 7 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/RateLimit/Adapters/FileRateLimitAdapter.php b/src/RateLimit/Adapters/FileRateLimitAdapter.php index 616e65ad9..1a3abe4dc 100644 --- a/src/RateLimit/Adapters/FileRateLimitAdapter.php +++ b/src/RateLimit/Adapters/FileRateLimitAdapter.php @@ -68,4 +68,15 @@ public function reset(string $key, int $count = 0): void 'reset_at' => time() + $this->resetInterval, ], $this->resetInterval); } + + public function retryAfter(string $key): int + { + $data = $this->cache->get($key); + + if (!is_array($data) || !isset($data['reset_at'])) { + return 0; + } + + return max(0, (int) $data['reset_at'] - time()); + } } diff --git a/src/RateLimit/Adapters/RedisRateLimitAdapter.php b/src/RateLimit/Adapters/RedisRateLimitAdapter.php index 77a0cda60..9da01edcc 100644 --- a/src/RateLimit/Adapters/RedisRateLimitAdapter.php +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -68,4 +68,15 @@ public function reset(string $key, int $count = 0): void 'reset_at' => time() + $this->resetInterval, ], $this->resetInterval); } + + public function retryAfter(string $key): int + { + $data = $this->cache->get($key); + + if (!is_array($data) || !isset($data['reset_at'])) { + return 0; + } + + return max(0, (int) $data['reset_at'] - time()); + } } diff --git a/src/RateLimit/Contracts/RateLimitAdapterInterface.php b/src/RateLimit/Contracts/RateLimitAdapterInterface.php index 47fae2f7a..2b48cef53 100644 --- a/src/RateLimit/Contracts/RateLimitAdapterInterface.php +++ b/src/RateLimit/Contracts/RateLimitAdapterInterface.php @@ -21,4 +21,6 @@ interface RateLimitAdapterInterface public function hit(string $key, int $limit, int $interval): bool; public function reset(string $key, int $count = 0): void; + + public function retryAfter(string $key): int; } diff --git a/src/RateLimit/RateLimitMiddleware.php b/src/RateLimit/RateLimitMiddleware.php index 64e7de2ab..bdc996a59 100644 --- a/src/RateLimit/RateLimitMiddleware.php +++ b/src/RateLimit/RateLimitMiddleware.php @@ -56,10 +56,15 @@ public function apply(Request $request, Closure $next): Response $allowed = $limiter->hit($method, $pattern, $ip, $limit, $interval); if (!$allowed) { + $retryAfter = $limiter->retryAfter($method, $pattern, $ip); + if ($retryAfter <= 0) { + $retryAfter = $interval; + } + return response() ->setHeader('X-RateLimit-Limit', (string) $limit) ->setHeader('X-RateLimit-Remaining', '0') - ->setHeader('Retry-After', (string) $interval) + ->setHeader('Retry-After', (string) $retryAfter) ->json(['message' => 'Too Many Requests'], StatusCode::TOO_MANY_REQUESTS); } diff --git a/src/RateLimit/RateLimiter.php b/src/RateLimit/RateLimiter.php index 7578e5932..8608c4956 100644 --- a/src/RateLimit/RateLimiter.php +++ b/src/RateLimit/RateLimiter.php @@ -51,6 +51,13 @@ public function reset(string $method, string $routePattern, ?string $ip, int $co $this->adapter->reset($this->buildKey($method, $routePattern, $ip), $count); } + public function retryAfter(string $method, string $routePattern, ?string $ip): int + { + return $this->adapter->retryAfter( + $this->buildKey($method, $routePattern, $ip) + ); + } + public function buildKey(string $method, string $routePattern, ?string $ip): string { $normalizedMethod = strtoupper(trim($method)); diff --git a/tests/Unit/RateLimit/RateLimitMiddlewareTest.php b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php index f4aa3f0e6..b94b97df9 100644 --- a/tests/Unit/RateLimit/RateLimitMiddlewareTest.php +++ b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php @@ -53,7 +53,7 @@ public function testRateLimitMiddlewareBlocksWith429AndHeaders(): void $this->assertSame(429, $second->getStatusCode()); $this->assertSame('1', $second->getHeader('X-RateLimit-Limit')); $this->assertSame('0', $second->getHeader('X-RateLimit-Remaining')); - $this->assertSame('60', $second->getHeader('Retry-After')); + $this->assertSame('7', $second->getHeader('Retry-After')); $this->assertSame('{"message":"Too Many Requests"}', $second->getContent()); } } @@ -71,4 +71,9 @@ public function hit(string $key, int $limit, int $interval): bool public function reset(string $key, int $count = 0): void { } + + public function retryAfter(string $key): int + { + return 7; + } } diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php index 3d2c5abcb..d2596d0d7 100644 --- a/tests/Unit/RateLimit/RateLimiterTest.php +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -19,6 +19,11 @@ public function hit(string $key, int $limit, int $interval): bool public function reset(string $key, int $count = 0): void { } + + public function retryAfter(string $key): int + { + return 0; + } }; $limiter = new RateLimiter($adapter); @@ -40,6 +45,11 @@ public function hit(string $key, int $limit, int $interval): bool public function reset(string $key, int $count = 0): void { } + + public function retryAfter(string $key): int + { + return 0; + } }; $limiter = new RateLimiter($adapter); @@ -68,6 +78,11 @@ public function hit(string $key, int $limit, int $interval): bool public function reset(string $key, int $count = 0): void { } + + public function retryAfter(string $key): int + { + return 0; + } }; $limiter = new RateLimiter($adapter); @@ -99,6 +114,11 @@ public function reset(string $key, int $count = 0): void { $this->calls[] = [$key, $count]; } + + public function retryAfter(string $key): int + { + return 0; + } }; $limiter = new RateLimiter($adapter); @@ -107,4 +127,27 @@ public function reset(string $key, int $count = 0): void $this->assertSame([['POST:/api/posts:10.0.0.2', 3]], $calls); } + + public function testRateLimiterDelegatesRetryAfterToAdapter(): void + { + $adapter = new class implements RateLimitAdapterInterface { + public function hit(string $key, int $limit, int $interval): bool + { + return true; + } + + public function reset(string $key, int $count = 0): void + { + } + + public function retryAfter(string $key): int + { + return $key === 'GET:/status:10.0.0.1' ? 7 : 0; + } + }; + + $limiter = new RateLimiter($adapter); + + $this->assertSame(7, $limiter->retryAfter('get', '/status', '10.0.0.1')); + } } From 0213c7de1b7f94b88df41aae60ede9a08ade0ac0 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 20:48:58 +0400 Subject: [PATCH 3/8] [#290] Replace rate-limit file storage cache dependency with fs --- .../Adapters/FileRateLimitAdapter.php | 124 ++++++++++++++---- .../Adapters/RedisRateLimitAdapter.php | 53 +++----- .../Factories/RateLimiterFactory.php | 23 +++- .../Adapters/FileRateLimitAdapterTest.php | 46 ++++++- .../Adapters/RedisRateLimitAdapterTest.php | 31 +++-- tests/Unit/RateLimit/RateLimiterTest.php | 2 +- 6 files changed, 202 insertions(+), 77 deletions(-) diff --git a/src/RateLimit/Adapters/FileRateLimitAdapter.php b/src/RateLimit/Adapters/FileRateLimitAdapter.php index 1a3abe4dc..dcc72f88a 100644 --- a/src/RateLimit/Adapters/FileRateLimitAdapter.php +++ b/src/RateLimit/Adapters/FileRateLimitAdapter.php @@ -16,62 +16,100 @@ namespace Quantum\RateLimit\Adapters; +use Quantum\App\Exceptions\BaseException; +use Quantum\Config\Exceptions\ConfigException; +use Quantum\Di\Exceptions\DiException; use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; -use Quantum\Cache\Cache; +use Quantum\Storage\FileSystem; +use ReflectionException; class FileRateLimitAdapter implements RateLimitAdapterInterface { - private Cache $cache; + private FileSystem $fs; private int $resetInterval; - public function __construct(?Cache $cache = null, int $resetInterval = 60) + private string $path; + + private string $prefix; + + /** + * @param array $params + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + public function __construct(array $params) { - $this->cache = $cache ?? cache('file'); - $this->resetInterval = $resetInterval; + $this->resetInterval = $params['ttl']; + $this->path = $params['path']; + $this->prefix = $params['prefix']; + $this->fs = fs(); + + if (!$this->fs->isDirectory($this->path)) { + $this->fs->makeDirectory($this->path); + } } public function hit(string $key, int $limit, int $interval): bool { $now = time(); - $data = $this->cache->get($key); - - if (!is_array($data) || !isset($data['count'], $data['reset_at']) || $now >= (int) $data['reset_at']) { - $count = 0; - $resetAt = $now + $interval; - } else { - $count = (int) $data['count']; - $resetAt = (int) $data['reset_at']; - } + $statePath = $this->getStatePath($key); + $lockPath = $this->getLockPath($key); + $lockHandle = fopen($lockPath, 'c+'); - $count++; + if ($lockHandle === false) { + return false; + } - $ttl = max(1, $resetAt - $now); + if (!flock($lockHandle, LOCK_EX)) { + fclose($lockHandle); + return false; + } - $this->cache->set($key, [ - 'count' => $count, - 'reset_at' => $resetAt, - ], $ttl); + try { + $data = $this->readState($statePath); + + if (!is_array($data) || !isset($data['count'], $data['reset_at']) || $now >= (int) $data['reset_at']) { + $count = 0; + $resetAt = $now + $interval; + } else { + $count = $data['count']; + $resetAt = $data['reset_at']; + } + + $count++; + + $this->writeState($statePath, [ + 'count' => $count, + 'reset_at' => $resetAt, + ]); + } finally { + flock($lockHandle, LOCK_UN); + fclose($lockHandle); + } return $count <= $limit; } public function reset(string $key, int $count = 0): void { + $statePath = $this->getStatePath($key); + if ($count <= 0) { - $this->cache->delete($key); + if ($this->fs->exists($statePath)) { + $this->fs->remove($statePath); + } return; } - $this->cache->set($key, [ + $this->writeState($statePath, [ 'count' => $count, 'reset_at' => time() + $this->resetInterval, - ], $this->resetInterval); + ]); } public function retryAfter(string $key): int { - $data = $this->cache->get($key); + $data = $this->readState($this->getStatePath($key)); if (!is_array($data) || !isset($data['reset_at'])) { return 0; @@ -79,4 +117,42 @@ public function retryAfter(string $key): int return max(0, (int) $data['reset_at'] - time()); } + + private function getStatePath(string $key): string + { + return $this->path . DS . md5($this->prefix . $key) . '.rate'; + } + + private function getLockPath(string $key): string + { + return $this->path . DS . md5($this->prefix . $key) . '.lock'; + } + + /** + * @return array|null + */ + private function readState(string $statePath): ?array + { + if (!$this->fs->exists($statePath)) { + return null; + } + + $raw = $this->fs->get($statePath); + + if (!is_string($raw) || $raw === '') { + return null; + } + + $data = json_decode($raw, true); + + return is_array($data) ? $data : null; + } + + /** + * @param array $state + */ + private function writeState(string $statePath, array $state): void + { + $this->fs->put($statePath, (string) json_encode($state)); + } } diff --git a/src/RateLimit/Adapters/RedisRateLimitAdapter.php b/src/RateLimit/Adapters/RedisRateLimitAdapter.php index 9da01edcc..256c5981f 100644 --- a/src/RateLimit/Adapters/RedisRateLimitAdapter.php +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -17,66 +17,51 @@ namespace Quantum\RateLimit\Adapters; use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; -use Quantum\Cache\Cache; +use RedisException; +use Redis; class RedisRateLimitAdapter implements RateLimitAdapterInterface { - private Cache $cache; + private Redis $redis; private int $resetInterval; - public function __construct(?Cache $cache = null, int $resetInterval = 60) + /** + * @param array $params + * @throws RedisException + */ + public function __construct(array $params) { - $this->cache = $cache ?? cache('redis'); - $this->resetInterval = $resetInterval; + $this->resetInterval = $params['ttl']; + + $this->redis = new Redis(); + $this->redis->connect($params['host'], $params['port']); } public function hit(string $key, int $limit, int $interval): bool { - $now = time(); - $data = $this->cache->get($key); + $count = (int) $this->redis->incr($key); - if (!is_array($data) || !isset($data['count'], $data['reset_at']) || $now >= (int) $data['reset_at']) { - $count = 0; - $resetAt = $now + $interval; - } else { - $count = (int) $data['count']; - $resetAt = (int) $data['reset_at']; + if ($count === 1) { + $this->redis->expire($key, $interval); } - $count++; - - $ttl = max(1, $resetAt - $now); - - $this->cache->set($key, [ - 'count' => $count, - 'reset_at' => $resetAt, - ], $ttl); - return $count <= $limit; } public function reset(string $key, int $count = 0): void { if ($count <= 0) { - $this->cache->delete($key); + $this->redis->del($key); return; } - $this->cache->set($key, [ - 'count' => $count, - 'reset_at' => time() + $this->resetInterval, - ], $this->resetInterval); + $this->redis->setex($key, $this->resetInterval, (string) $count); } public function retryAfter(string $key): int { - $data = $this->cache->get($key); - - if (!is_array($data) || !isset($data['reset_at'])) { - return 0; - } - - return max(0, (int) $data['reset_at'] - time()); + $ttl = (int) $this->redis->ttl($key); + return $ttl > 0 ? $ttl : 0; } } diff --git a/src/RateLimit/Factories/RateLimiterFactory.php b/src/RateLimit/Factories/RateLimiterFactory.php index 00b5ee1be..39bfd7733 100644 --- a/src/RateLimit/Factories/RateLimiterFactory.php +++ b/src/RateLimit/Factories/RateLimiterFactory.php @@ -62,12 +62,15 @@ public function resolve(?string $adapter = null): RateLimiter config()->import(new Setup('config', 'rate_limit')); } + if (!config()->has('cache')) { + config()->import(new Setup('config', 'cache')); + } + $adapter ??= (string) config()->get('rate_limit.default', RateLimitType::FILE); + $adapterClass = $this->getAdapterClass($adapter); if (!isset($this->instances[$adapter])) { - $this->instances[$adapter] = new RateLimiter( - $this->createAdapter($adapter) - ); + $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter); } return $this->instances[$adapter]; @@ -76,7 +79,7 @@ public function resolve(?string $adapter = null): RateLimiter /** * @throws BaseException */ - private function createAdapter(string $adapter): RateLimitAdapterInterface + private function getAdapterClass(string $adapter): string { $class = self::ADAPTERS[$adapter] ?? null; @@ -84,6 +87,16 @@ private function createAdapter(string $adapter): RateLimitAdapterInterface throw RateLimitException::adapterNotSupported($adapter); } - return new $class(); + return $class; + } + + private function createInstance(string $adapterClass, string $adapter): RateLimiter + { + $params = (array) config()->get('cache.' . $adapter, []); + + /** @var RateLimitAdapterInterface $adapterInstance */ + $adapterInstance = new $adapterClass($params); + + return new RateLimiter($adapterInstance); } } diff --git a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php index e598544cf..4feb7f2ef 100644 --- a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php @@ -3,16 +3,39 @@ namespace Quantum\Tests\Unit\RateLimit\Adapters; use Quantum\RateLimit\Adapters\FileRateLimitAdapter; -use Quantum\Tests\Helpers\InMemoryPsrCache; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Cache\Cache; class FileRateLimitAdapterTest extends AppTestCase { + private string $rateLimitDir; + + public function setUp(): void + { + parent::setUp(); + $this->rateLimitDir = base_dir() . DS . 'cache' . DS . 'rate_limit_tests'; + if (!fs()->isDirectory($this->rateLimitDir)) { + fs()->makeDirectory($this->rateLimitDir); + } + } + + public function tearDown(): void + { + $files = fs()->glob($this->rateLimitDir . DS . '*') ?: []; + foreach ($files as $file) { + fs()->remove($file); + } + if (fs()->isDirectory($this->rateLimitDir)) { + fs()->removeDirectory($this->rateLimitDir); + } + } + public function testFileAdapterHitAndResetFlow(): void { - $cache = new Cache(new InMemoryPsrCache()); - $adapter = new FileRateLimitAdapter($cache, 30); + $adapter = new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $this->rateLimitDir, + ]); $this->assertTrue($adapter->hit('k1', 2, 60)); $this->assertTrue($adapter->hit('k1', 2, 60)); @@ -21,4 +44,19 @@ public function testFileAdapterHitAndResetFlow(): void $adapter->reset('k1'); $this->assertTrue($adapter->hit('k1', 2, 60)); } + + public function testFileAdapterRepeatedHitsRespectExactLimitBoundary(): void + { + $adapter = new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $this->rateLimitDir, + ]); + + for ($i = 0; $i < 100; $i++) { + $this->assertTrue($adapter->hit('boundary', 100, 60)); + } + + $this->assertFalse($adapter->hit('boundary', 100, 60)); + } } diff --git a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php index 414e57878..df00c4190 100644 --- a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php @@ -3,21 +3,34 @@ namespace Quantum\Tests\Unit\RateLimit\Adapters; use Quantum\RateLimit\Adapters\RedisRateLimitAdapter; -use Quantum\Tests\Helpers\InMemoryPsrCache; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Cache\Cache; class RedisRateLimitAdapterTest extends AppTestCase { - public function testRedisAdapterHitAndResetFlow(): void + private RedisRateLimitAdapter $adapter; + + public function setUp(): void { - $cache = new Cache(new InMemoryPsrCache()); - $adapter = new RedisRateLimitAdapter($cache, 30); + parent::setUp(); - $this->assertTrue($adapter->hit('k2', 1, 60)); - $this->assertFalse($adapter->hit('k2', 1, 60)); + $this->adapter = new RedisRateLimitAdapter([ + 'host' => '127.0.0.1', + 'port' => 6379, + 'ttl' => 30, + ]); + } + + public function tearDown(): void + { + $this->adapter->reset('k2'); + } + + public function testRedisAdapterHitAndResetFlow(): void + { + $this->assertTrue($this->adapter->hit('k2', 1, 60)); + $this->assertFalse($this->adapter->hit('k2', 1, 60)); - $adapter->reset('k2', 1); - $this->assertFalse($adapter->hit('k2', 1, 60)); + $this->adapter->reset('k2', 1); + $this->assertFalse($this->adapter->hit('k2', 1, 60)); } } diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php index d2596d0d7..57405865d 100644 --- a/tests/Unit/RateLimit/RateLimiterTest.php +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -130,7 +130,7 @@ public function retryAfter(string $key): int public function testRateLimiterDelegatesRetryAfterToAdapter(): void { - $adapter = new class implements RateLimitAdapterInterface { + $adapter = new class () implements RateLimitAdapterInterface { public function hit(string $key, int $limit, int $interval): bool { return true; From ed527841f2a6d8314054461a4b68d8d420c104b4 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 21:47:23 +0400 Subject: [PATCH 4/8] [#290] Validate positive rate limit values at route definition --- src/Router/Route.php | 9 +++++++++ tests/Unit/Router/RouteTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Router/Route.php b/src/Router/Route.php index b2305789a..d27176f03 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -17,6 +17,7 @@ namespace Quantum\Router; use Quantum\Router\Exceptions\RouteException; +use InvalidArgumentException; use Closure; /** @@ -154,6 +155,14 @@ public function getCache(): ?array */ public function rateLimit(int $limit, int $interval): self { + if ($limit <= 0) { + throw new InvalidArgumentException('Invalid parameter $limit: must be greater than 0.'); + } + + if ($interval <= 0) { + throw new InvalidArgumentException('Invalid parameter $interval: must be greater than 0.'); + } + $this->rateLimit = [ 'limit' => $limit, 'interval' => $interval, diff --git a/tests/Unit/Router/RouteTest.php b/tests/Unit/Router/RouteTest.php index 0abe6c1c8..f9e44955c 100644 --- a/tests/Unit/Router/RouteTest.php +++ b/tests/Unit/Router/RouteTest.php @@ -5,6 +5,7 @@ use Quantum\Router\Exceptions\RouteException; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\Route; +use InvalidArgumentException; class RouteTest extends AppTestCase { @@ -203,4 +204,34 @@ public function testRouteToArrayIncludesRateLimit(): void $this->assertSame(['limit' => 50, 'interval' => 30], $data['rateLimit']); } + + public function testRouteRateLimitRejectsNonPositiveLimit(): void + { + $route = new Route( + ['GET'], + 'posts', + 'PostController', + 'indexAction' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid parameter $limit: must be greater than 0.'); + + $route->rateLimit(0, 60); + } + + public function testRouteRateLimitRejectsNonPositiveInterval(): void + { + $route = new Route( + ['GET'], + 'posts', + 'PostController', + 'indexAction' + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid parameter $interval: must be greater than 0.'); + + $route->rateLimit(100, 0); + } } From 592bb9d68553e55cfec9adba9f325a761e3a539d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 21:52:48 +0400 Subject: [PATCH 5/8] [#290] Cover in-group route rateLimit builder branch --- tests/Unit/Router/RouteBuilderTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Unit/Router/RouteBuilderTest.php b/tests/Unit/Router/RouteBuilderTest.php index e64b82de3..f94807a71 100644 --- a/tests/Unit/Router/RouteBuilderTest.php +++ b/tests/Unit/Router/RouteBuilderTest.php @@ -419,6 +419,30 @@ public function testRouteBuilderRateLimitAppliesToGroupRoutes(): void } } + public function testRouteBuilderRateLimitAppliedInsideActiveGroupCallback(): void + { + $builder = new RouteBuilder(); + + $routes = $builder->build( + [ + 'Web' => function (RouteBuilder $route): void { + $route->group('api', function (RouteBuilder $route): void { + $route->get('a', 'AController', 'a'); + $route->get('b', 'BController', 'b'); + $route->rateLimit(25, 15); + }); + }, + ], + [] + ); + + foreach ($routes->all() as $item) { + $rateLimit = $item->getRateLimit(); + $this->assertSame(25, $rateLimit['limit']); + $this->assertSame(15, $rateLimit['interval']); + } + } + public function testRouteBuilderAddRouteRequiresControllerAndAction(): void { $this->expectException(RouteException::class); From f441f46a75cc89543b52fc997a001d95975235db Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:13 +0400 Subject: [PATCH 6/8] [#290] Add targeted tests for rate limit edge branches --- .../Adapters/FileRateLimitAdapterTest.php | 35 +++++++++++++ .../Adapters/RedisRateLimitAdapterTest.php | 6 +++ .../RateLimit/RateLimitMiddlewareTest.php | 52 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php index 4feb7f2ef..d786f124f 100644 --- a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php @@ -59,4 +59,39 @@ public function testFileAdapterRepeatedHitsRespectExactLimitBoundary(): void $this->assertFalse($adapter->hit('boundary', 100, 60)); } + + public function testFileAdapterCreatesStorageDirectoryWhenMissing(): void + { + $path = base_dir() . DS . 'cache' . DS . 'rate_limit_tests_missing'; + + if (fs()->isDirectory($path)) { + fs()->removeDirectory($path); + } + + new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $path, + ]); + + $this->assertTrue(fs()->isDirectory($path)); + + fs()->removeDirectory($path); + } + + public function testFileAdapterRetryAfterReturnsZeroForMissingOrInvalidState(): void + { + $adapter = new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $this->rateLimitDir, + ]); + + $this->assertSame(0, $adapter->retryAfter('missing-key')); + + $statePath = $this->rateLimitDir . DS . md5('test' . 'broken') . '.rate'; + fs()->put($statePath, '{invalid-json'); + + $this->assertSame(0, $adapter->retryAfter('broken')); + } } diff --git a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php index df00c4190..075a1c0c9 100644 --- a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php @@ -33,4 +33,10 @@ public function testRedisAdapterHitAndResetFlow(): void $this->adapter->reset('k2', 1); $this->assertFalse($this->adapter->hit('k2', 1, 60)); } + + public function testRedisAdapterRetryAfterReturnsZeroForMissingKey(): void + { + $this->adapter->reset('missing-key'); + $this->assertSame(0, $this->adapter->retryAfter('missing-key')); + } } diff --git a/tests/Unit/RateLimit/RateLimitMiddlewareTest.php b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php index b94b97df9..a610ad955 100644 --- a/tests/Unit/RateLimit/RateLimitMiddlewareTest.php +++ b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php @@ -56,6 +56,41 @@ public function testRateLimitMiddlewareBlocksWith429AndHeaders(): void $this->assertSame('7', $second->getHeader('Retry-After')); $this->assertSame('{"message":"Too Many Requests"}', $second->getContent()); } + + public function testRateLimitMiddlewarePassesThroughWhenRouteHasNoRateLimit(): void + { + $route = new Route(['GET'], '/posts', 'PostController', 'index'); + $middleware = new RateLimitMiddleware($route); + + $response = $middleware->apply( + request(), + fn (Request $request): Response => response()->json(['status' => 'ok']) + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull($response->getHeader('X-RateLimit-Limit')); + } + + public function testRateLimitMiddlewareFallsBackToIntervalWhenRetryAfterIsZero(): void + { + $factory = Di::get(RateLimiterFactory::class); + $this->setPrivateProperty($factory, 'instances', [ + 'file' => new RateLimiter(new RetryAfterZeroAdapter()), + ]); + + $route = new Route(['GET'], '/posts', 'PostController', 'index'); + $route->rateLimit(1, 60); + + $middleware = new RateLimitMiddleware($route); + + $response = $middleware->apply( + request(), + fn (Request $request): Response => response()->json(['status' => 'ok']) + ); + + $this->assertSame(429, $response->getStatusCode()); + $this->assertSame('60', $response->getHeader('Retry-After')); + } } class ToggleAdapter implements RateLimitAdapterInterface @@ -77,3 +112,20 @@ public function retryAfter(string $key): int return 7; } } + +class RetryAfterZeroAdapter implements RateLimitAdapterInterface +{ + public function hit(string $key, int $limit, int $interval): bool + { + return false; + } + + public function reset(string $key, int $count = 0): void + { + } + + public function retryAfter(string $key): int + { + return 0; + } +} From 10ad3633ae8848e4e94aa5909fe03fa8db611003 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 8 May 2026 15:38:44 +0400 Subject: [PATCH 7/8] [#290] Decouple rate limiter config and align adapter behavior --- src/Middleware/MiddlewareManager.php | 10 ++++------ .../Adapters/FileRateLimitAdapter.php | 10 +++++++--- .../Adapters/RedisRateLimitAdapter.php | 20 ++++++++++++++----- .../Contracts/RateLimitAdapterInterface.php | 4 ++++ src/RateLimit/Enums/ExceptionMessages.php | 4 ++++ .../Exceptions/RateLimitException.php | 4 ++++ .../Factories/RateLimiterFactory.php | 10 +++++----- src/RateLimit/RateLimitMiddleware.php | 4 ++++ src/RateLimit/RateLimiter.php | 4 ++++ tests/_root/shared/config/rate_limit.php | 11 ++++++++++ 10 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/Middleware/MiddlewareManager.php b/src/Middleware/MiddlewareManager.php index 4090781ad..8e4da2c21 100644 --- a/src/Middleware/MiddlewareManager.php +++ b/src/Middleware/MiddlewareManager.php @@ -48,12 +48,10 @@ class MiddlewareManager public function __construct(MatchedRoute $matchedRoute) { - $route = $matchedRoute->getRoute(); - $this->route = $route; - - $this->middlewares = array_values($route->getMiddlewares() ?? []); - $this->module = $route->getModule(); - $this->hasRateLimit = $route->getRateLimit() !== null; + $this->route = $matchedRoute->getRoute(); + $this->middlewares = array_values($this->route->getMiddlewares() ?? []); + $this->module = $this->route->getModule(); + $this->hasRateLimit = $this->route->getRateLimit() !== null; } /** diff --git a/src/RateLimit/Adapters/FileRateLimitAdapter.php b/src/RateLimit/Adapters/FileRateLimitAdapter.php index dcc72f88a..2937e3a75 100644 --- a/src/RateLimit/Adapters/FileRateLimitAdapter.php +++ b/src/RateLimit/Adapters/FileRateLimitAdapter.php @@ -16,13 +16,17 @@ namespace Quantum\RateLimit\Adapters; -use Quantum\App\Exceptions\BaseException; +use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; -use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; use Quantum\Storage\FileSystem; use ReflectionException; +/** + * Class FileRateLimitAdapter + * @package Quantum\RateLimit + */ class FileRateLimitAdapter implements RateLimitAdapterInterface { private FileSystem $fs; @@ -41,7 +45,7 @@ public function __construct(array $params) { $this->resetInterval = $params['ttl']; $this->path = $params['path']; - $this->prefix = $params['prefix']; + $this->prefix = (string) ($params['prefix'] ?? ''); $this->fs = fs(); if (!$this->fs->isDirectory($this->path)) { diff --git a/src/RateLimit/Adapters/RedisRateLimitAdapter.php b/src/RateLimit/Adapters/RedisRateLimitAdapter.php index 256c5981f..076ca1e28 100644 --- a/src/RateLimit/Adapters/RedisRateLimitAdapter.php +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -20,12 +20,18 @@ use RedisException; use Redis; +/** + * Class RedisRateLimitAdapter + * @package Quantum\RateLimit + */ class RedisRateLimitAdapter implements RateLimitAdapterInterface { private Redis $redis; private int $resetInterval; + private string $prefix; + /** * @param array $params * @throws RedisException @@ -33,6 +39,7 @@ class RedisRateLimitAdapter implements RateLimitAdapterInterface public function __construct(array $params) { $this->resetInterval = $params['ttl']; + $this->prefix = (string) ($params['prefix'] ?? ''); $this->redis = new Redis(); $this->redis->connect($params['host'], $params['port']); @@ -40,10 +47,11 @@ public function __construct(array $params) public function hit(string $key, int $limit, int $interval): bool { - $count = (int) $this->redis->incr($key); + $namespacedKey = $this->prefix . $key; + $count = (int) $this->redis->incr($namespacedKey); if ($count === 1) { - $this->redis->expire($key, $interval); + $this->redis->expire($namespacedKey, $interval); } return $count <= $limit; @@ -51,17 +59,19 @@ public function hit(string $key, int $limit, int $interval): bool public function reset(string $key, int $count = 0): void { + $namespacedKey = $this->prefix . $key; + if ($count <= 0) { - $this->redis->del($key); + $this->redis->del($namespacedKey); return; } - $this->redis->setex($key, $this->resetInterval, (string) $count); + $this->redis->setex($namespacedKey, $this->resetInterval, (string) $count); } public function retryAfter(string $key): int { - $ttl = (int) $this->redis->ttl($key); + $ttl = (int) $this->redis->ttl($this->prefix . $key); return $ttl > 0 ? $ttl : 0; } } diff --git a/src/RateLimit/Contracts/RateLimitAdapterInterface.php b/src/RateLimit/Contracts/RateLimitAdapterInterface.php index 2b48cef53..63640802a 100644 --- a/src/RateLimit/Contracts/RateLimitAdapterInterface.php +++ b/src/RateLimit/Contracts/RateLimitAdapterInterface.php @@ -16,6 +16,10 @@ namespace Quantum\RateLimit\Contracts; +/** + * Interface RateLimitAdapterInterface + * @package Quantum\RateLimit + */ interface RateLimitAdapterInterface { public function hit(string $key, int $limit, int $interval): bool; diff --git a/src/RateLimit/Enums/ExceptionMessages.php b/src/RateLimit/Enums/ExceptionMessages.php index b0f28bebe..45704403e 100644 --- a/src/RateLimit/Enums/ExceptionMessages.php +++ b/src/RateLimit/Enums/ExceptionMessages.php @@ -18,6 +18,10 @@ use Quantum\App\Enums\ExceptionMessages as BaseExceptionMessages; +/** + * Class ExceptionMessages + * @package Quantum\RateLimit + */ final class ExceptionMessages extends BaseExceptionMessages { public const ADAPTER_NOT_SUPPORTED = 'Rate limit adapter `{%1}` is not supported.'; diff --git a/src/RateLimit/Exceptions/RateLimitException.php b/src/RateLimit/Exceptions/RateLimitException.php index 1b4b4499e..741b57465 100644 --- a/src/RateLimit/Exceptions/RateLimitException.php +++ b/src/RateLimit/Exceptions/RateLimitException.php @@ -19,6 +19,10 @@ use Quantum\RateLimit\Enums\ExceptionMessages; use Quantum\App\Exceptions\BaseException; +/** + * Class RateLimitException + * @package Quantum\RateLimit + */ class RateLimitException extends BaseException { public static function adapterNotSupported(string $adapter): self diff --git a/src/RateLimit/Factories/RateLimiterFactory.php b/src/RateLimit/Factories/RateLimiterFactory.php index 39bfd7733..d3f335980 100644 --- a/src/RateLimit/Factories/RateLimiterFactory.php +++ b/src/RateLimit/Factories/RateLimiterFactory.php @@ -29,6 +29,10 @@ use ReflectionException; use Quantum\Di\Di; +/** + * Class RateLimiterFactory + * @package Quantum\RateLimit + */ class RateLimiterFactory { public const ADAPTERS = [ @@ -62,10 +66,6 @@ public function resolve(?string $adapter = null): RateLimiter config()->import(new Setup('config', 'rate_limit')); } - if (!config()->has('cache')) { - config()->import(new Setup('config', 'cache')); - } - $adapter ??= (string) config()->get('rate_limit.default', RateLimitType::FILE); $adapterClass = $this->getAdapterClass($adapter); @@ -92,7 +92,7 @@ private function getAdapterClass(string $adapter): string private function createInstance(string $adapterClass, string $adapter): RateLimiter { - $params = (array) config()->get('cache.' . $adapter, []); + $params = (array) config()->get('rate_limit.' . $adapter, []); /** @var RateLimitAdapterInterface $adapterInstance */ $adapterInstance = new $adapterClass($params); diff --git a/src/RateLimit/RateLimitMiddleware.php b/src/RateLimit/RateLimitMiddleware.php index bdc996a59..95851020d 100644 --- a/src/RateLimit/RateLimitMiddleware.php +++ b/src/RateLimit/RateLimitMiddleware.php @@ -24,6 +24,10 @@ use Quantum\Http\Request; use Closure; +/** + * Class RateLimitMiddleware + * @package Quantum\RateLimit + */ class RateLimitMiddleware extends Middleware { private Route $route; diff --git a/src/RateLimit/RateLimiter.php b/src/RateLimit/RateLimiter.php index 8608c4956..f18ac478a 100644 --- a/src/RateLimit/RateLimiter.php +++ b/src/RateLimit/RateLimiter.php @@ -18,6 +18,10 @@ use Quantum\RateLimit\Contracts\RateLimitAdapterInterface; +/** + * Class RateLimiter + * @package Quantum\RateLimit + */ class RateLimiter { private RateLimitAdapterInterface $adapter; diff --git a/tests/_root/shared/config/rate_limit.php b/tests/_root/shared/config/rate_limit.php index f6cddd9bf..dc8019801 100644 --- a/tests/_root/shared/config/rate_limit.php +++ b/tests/_root/shared/config/rate_limit.php @@ -2,4 +2,15 @@ return [ 'default' => 'file', + 'file' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'path' => base_dir() . DS . 'cache' . DS . 'data', + 'ttl' => 600, + ], + 'redis' => [ + 'prefix' => str_replace(' ', '', env('APP_NAME') ?? ''), + 'host' => '127.0.0.1', + 'port' => 6379, + 'ttl' => 60, + ], ]; From 8c5f88df17568a12a4106c0aa2602727f9bd0470 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 8 May 2026 16:11:37 +0400 Subject: [PATCH 8/8] [#290] Harden file adapter tests against filename coupling --- .../Adapters/FileRateLimitAdapterTest.php | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php index d786f124f..589f30aac 100644 --- a/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php @@ -68,15 +68,19 @@ public function testFileAdapterCreatesStorageDirectoryWhenMissing(): void fs()->removeDirectory($path); } - new FileRateLimitAdapter([ - 'ttl' => 30, - 'prefix' => 'test', - 'path' => $path, - ]); - - $this->assertTrue(fs()->isDirectory($path)); - - fs()->removeDirectory($path); + try { + new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $path, + ]); + + $this->assertTrue(fs()->isDirectory($path)); + } finally { + if (fs()->isDirectory($path)) { + fs()->removeDirectory($path); + } + } } public function testFileAdapterRetryAfterReturnsZeroForMissingOrInvalidState(): void @@ -89,8 +93,11 @@ public function testFileAdapterRetryAfterReturnsZeroForMissingOrInvalidState(): $this->assertSame(0, $adapter->retryAfter('missing-key')); - $statePath = $this->rateLimitDir . DS . md5('test' . 'broken') . '.rate'; - fs()->put($statePath, '{invalid-json'); + $adapter->hit('broken', 1, 60); + $stateFiles = fs()->glob($this->rateLimitDir . DS . '*.rate') ?: []; + $this->assertCount(1, $stateFiles); + + fs()->put((string) current($stateFiles), '{invalid-json'); $this->assertSame(0, $adapter->retryAfter('broken')); }