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..8e4da2c21 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,48 @@ class MiddlewareManager */ private ?string $module; + private Route $route; + + private bool $hasRateLimit; + public function __construct(MatchedRoute $matchedRoute) { - $route = $matchedRoute->getRoute(); - - $this->middlewares = array_values($route->getMiddlewares() ?? []); - $this->module = $route->getModule(); + $this->route = $matchedRoute->getRoute(); + $this->middlewares = array_values($this->route->getMiddlewares() ?? []); + $this->module = $this->route->getModule(); + $this->hasRateLimit = $this->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 +92,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..2937e3a75 --- /dev/null +++ b/src/RateLimit/Adapters/FileRateLimitAdapter.php @@ -0,0 +1,162 @@ + + * @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\Config\Exceptions\ConfigException; +use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; +use Quantum\Storage\FileSystem; +use ReflectionException; + +/** + * Class FileRateLimitAdapter + * @package Quantum\RateLimit + */ +class FileRateLimitAdapter implements RateLimitAdapterInterface +{ + private FileSystem $fs; + + private int $resetInterval; + + private string $path; + + private string $prefix; + + /** + * @param array $params + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + public function __construct(array $params) + { + $this->resetInterval = $params['ttl']; + $this->path = $params['path']; + $this->prefix = (string) ($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(); + $statePath = $this->getStatePath($key); + $lockPath = $this->getLockPath($key); + $lockHandle = fopen($lockPath, 'c+'); + + if ($lockHandle === false) { + return false; + } + + if (!flock($lockHandle, LOCK_EX)) { + fclose($lockHandle); + return false; + } + + 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) { + if ($this->fs->exists($statePath)) { + $this->fs->remove($statePath); + } + return; + } + + $this->writeState($statePath, [ + 'count' => $count, + 'reset_at' => time() + $this->resetInterval, + ]); + } + + public function retryAfter(string $key): int + { + $data = $this->readState($this->getStatePath($key)); + + if (!is_array($data) || !isset($data['reset_at'])) { + return 0; + } + + 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 new file mode 100644 index 000000000..076ca1e28 --- /dev/null +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -0,0 +1,77 @@ + + * @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 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 + */ + 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']); + } + + public function hit(string $key, int $limit, int $interval): bool + { + $namespacedKey = $this->prefix . $key; + $count = (int) $this->redis->incr($namespacedKey); + + if ($count === 1) { + $this->redis->expire($namespacedKey, $interval); + } + + return $count <= $limit; + } + + public function reset(string $key, int $count = 0): void + { + $namespacedKey = $this->prefix . $key; + + if ($count <= 0) { + $this->redis->del($namespacedKey); + return; + } + + $this->redis->setex($namespacedKey, $this->resetInterval, (string) $count); + } + + public function retryAfter(string $key): int + { + $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 new file mode 100644 index 000000000..63640802a --- /dev/null +++ b/src/RateLimit/Contracts/RateLimitAdapterInterface.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\RateLimit\Contracts; + +/** + * Interface RateLimitAdapterInterface + * @package Quantum\RateLimit + */ +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/Enums/ExceptionMessages.php b/src/RateLimit/Enums/ExceptionMessages.php new file mode 100644 index 000000000..45704403e --- /dev/null +++ b/src/RateLimit/Enums/ExceptionMessages.php @@ -0,0 +1,28 @@ + + * @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; + +/** + * 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/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..741b57465 --- /dev/null +++ b/src/RateLimit/Exceptions/RateLimitException.php @@ -0,0 +1,35 @@ + + * @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 + * @package Quantum\RateLimit + */ +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..d3f335980 --- /dev/null +++ b/src/RateLimit/Factories/RateLimiterFactory.php @@ -0,0 +1,102 @@ + + * @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 + * @package Quantum\RateLimit + */ +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); + $adapterClass = $this->getAdapterClass($adapter); + + if (!isset($this->instances[$adapter])) { + $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter); + } + + return $this->instances[$adapter]; + } + + /** + * @throws BaseException + */ + private function getAdapterClass(string $adapter): string + { + $class = self::ADAPTERS[$adapter] ?? null; + + if ($class === null) { + throw RateLimitException::adapterNotSupported($adapter); + } + + return $class; + } + + private function createInstance(string $adapterClass, string $adapter): RateLimiter + { + $params = (array) config()->get('rate_limit.' . $adapter, []); + + /** @var RateLimitAdapterInterface $adapterInstance */ + $adapterInstance = new $adapterClass($params); + + return new RateLimiter($adapterInstance); + } +} diff --git a/src/RateLimit/RateLimitMiddleware.php b/src/RateLimit/RateLimitMiddleware.php new file mode 100644 index 000000000..95851020d --- /dev/null +++ b/src/RateLimit/RateLimitMiddleware.php @@ -0,0 +1,78 @@ + + * @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 + * @package Quantum\RateLimit + */ +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) { + $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) $retryAfter) + ->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..f18ac478a --- /dev/null +++ b/src/RateLimit/RateLimiter.php @@ -0,0 +1,81 @@ + + * @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 + * @package Quantum\RateLimit + */ +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 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)); + $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..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; /** @@ -69,6 +70,11 @@ final class Route */ protected ?array $cache = null; + /** + * @var array|null + */ + protected ?array $rateLimit = null; + /** * @var string|null */ @@ -143,6 +149,37 @@ public function getCache(): ?array return $this->cache; } + /** + * Configure rate limiting settings for this route. + * @return $this + */ + 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, + ]; + + 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 +369,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..589f30aac --- /dev/null +++ b/tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php @@ -0,0 +1,104 @@ +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 + { + $adapter = new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $this->rateLimitDir, + ]); + + $this->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)); + } + + 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)); + } + + public function testFileAdapterCreatesStorageDirectoryWhenMissing(): void + { + $path = base_dir() . DS . 'cache' . DS . 'rate_limit_tests_missing'; + + if (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 + { + $adapter = new FileRateLimitAdapter([ + 'ttl' => 30, + 'prefix' => 'test', + 'path' => $this->rateLimitDir, + ]); + + $this->assertSame(0, $adapter->retryAfter('missing-key')); + + $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')); + } +} diff --git a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php new file mode 100644 index 000000000..075a1c0c9 --- /dev/null +++ b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php @@ -0,0 +1,42 @@ +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)); + + $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/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..a610ad955 --- /dev/null +++ b/tests/Unit/RateLimit/RateLimitMiddlewareTest.php @@ -0,0 +1,131 @@ +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('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 +{ + 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 + { + } + + 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; + } +} diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php new file mode 100644 index 000000000..57405865d --- /dev/null +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -0,0 +1,153 @@ +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 + { + } + + public function retryAfter(string $key): int + { + return 0; + } + }; + + $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 + { + } + + public function retryAfter(string $key): int + { + return 0; + } + }; + + $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]; + } + + public function retryAfter(string $key): int + { + return 0; + } + }; + + $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); + } + + 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')); + } +} 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..f94807a71 100644 --- a/tests/Unit/Router/RouteBuilderTest.php +++ b/tests/Unit/Router/RouteBuilderTest.php @@ -376,6 +376,73 @@ 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 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); @@ -490,6 +557,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..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 { @@ -125,6 +126,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 +185,53 @@ 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']); + } + + 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); } } diff --git a/tests/_root/shared/config/rate_limit.php b/tests/_root/shared/config/rate_limit.php new file mode 100644 index 000000000..dc8019801 --- /dev/null +++ b/tests/_root/shared/config/rate_limit.php @@ -0,0 +1,16 @@ + '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, + ], +];