Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down Expand Up @@ -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
Expand Down
44 changes: 37 additions & 7 deletions src/Middleware/MiddlewareManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -41,19 +42,48 @@ class MiddlewareManager
*/
private ?string $module;

private Route $route;

private bool $hasRateLimit;

public function __construct(MatchedRoute $matchedRoute)
{
Comment thread
armanist marked this conversation as resolved.
$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);
Expand All @@ -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));
}

/**
Expand Down
162 changes: 162 additions & 0 deletions src/RateLimit/Adapters/FileRateLimitAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

/**
* Quantum PHP Framework
*
* An open source software development framework for PHP
*
* @package Quantum
* @author Arman Ag. <arman.ag@softberg.org>
* @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<string, mixed> $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<string, int>|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<string, int> $state
*/
private function writeState(string $statePath, array $state): void
{
$this->fs->put($statePath, (string) json_encode($state));
}
}
77 changes: 77 additions & 0 deletions src/RateLimit/Adapters/RedisRateLimitAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

/**
* Quantum PHP Framework
*
* An open source software development framework for PHP
*
* @package Quantum
* @author Arman Ag. <arman.ag@softberg.org>
* @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<string, mixed> $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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
}
}
30 changes: 30 additions & 0 deletions src/RateLimit/Contracts/RateLimitAdapterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/**
* Quantum PHP Framework
*
* An open source software development framework for PHP
*
* @package Quantum
* @author Arman Ag. <arman.ag@softberg.org>
* @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;
}
Loading
Loading