extras http and rate_limiting...

main
Robert 4 months ago
parent 1c633b9ef6
commit 809a95f4eb
  1. 72
      src/classes/apis/rate_limiting/api_authenticator.php
  2. 151
      src/classes/apis/rate_limiting/create_subscription_aware_validator.php
  3. 155
      src/classes/apis/rate_limiting/rate_limiter.php
  4. 111
      src/classes/apis/rate_limiting/redis_rate_limiter.php
  5. 109
      src/classes/http/container.php
  6. 100
      src/classes/http/kernel.php
  7. 67
      src/classes/http/request.php
  8. 52
      src/classes/http/response.php
  9. 53
      src/classes/http/service_provider.php

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace CodeHydrater\apis\rate_limiting;
use CodeHydrater\enums\api_tier as API_TIER;
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
class api_authenticator {
private $validation_callback;
private $rate_limiter;
public function __construct(API_TIER $tier, callable $validation_callback, $rate_limiter = null) {
$this->validation_callback = $validation_callback;
$this->rate_limiter = $rate_limiter;
}
function authenticate(string $provided_key): false|array {
if (empty($provided_key)) {
$headers = getallheaders();
$provided_key = $headers['X-API-KEY'] ?? $_GET['api_key'] ?? null;
}
if (strlen($provided_key) < 5) {
throw new Exception('API key required', 401);
}
// Rate limiting
if ($this->rate_limiter) {
$rate_limit_result = $this->rate_limiter->check_rate_limit($provided_key);
if (!$rate_limit_result['allowed']) {
header('X-RateLimit-Limit: ' . $rate_limiter->get_limit());
header('X-RateLimit-Remaining: ' . $rate_limit_result['remaining']);
header('X-RateLimit-Reset: ' . $rate_limit_result['reset']);
header('X-RateLimit-Burst-Limit: ' . $rate_limiter->get_burst_limit());
header('X-RateLimit-Burst-Remaining: ' . $rate_limit_result['burst_remaining']);
header('X-RateLimit-Burst-Reset: ' . $rate_limit_result['burst_reset']);
$message = $rate_limit_result['burst_remaining'] === 0 ? 'Burst rate limit exceeded' : 'Rate limit exceeded';
http_response_code(429);
die(json_encode([
'error' => $message,
'retry_after' => $rate_limit_result['burst_remaining'] === 0 ? $rate_limit_result['burst_reset'] - time() : $rate_limit_result['reset'] - time()
]));
}
// Add headers to successful requests
header('X-RateLimit-Limit: ' . $rate_limiter->get_limit());
header('X-RateLimit-Remaining: ' . $rate_limit_result['remaining']);
header('X-RateLimit-Reset: ' . $rate_limit_result['reset']);
header('X-RateLimit-Burst-Limit: ' . $rate_limiter->get_burst_limit());
header('X-RateLimit-Burst-Remaining: ' . $rate_limit_result['burst_remaining']);
header('X-RateLimit-Burst-Reset: ' . $rate_limit_result['burst_reset']);
}
// Execute validation callback
$customer_data = call_user_func($this->validation_callback, $provided_key);
if (!$customer_data) {
throw new Exception('Invalid API key', 403);
}
return $customer_data;
}
}

@ -0,0 +1,151 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\apis\rate_limiting;
class create_subscription_aware_validator {
public function validator(PDO $db): callable
{
return function(string $api_key) use ($db): array {
$stmt = $db->prepare("
SELECT
k.customer_id,
k.tier AS key_tier,
k.is_active,
k.expires_at,
s.plan_id,
p.name AS tier_name,
p.rate_limit,
p.burst_limit,
p.features
FROM customer_api_keys k
JOIN customers c ON k.customer_id = c.id
LEFT JOIN customer_subscriptions s ON
s.customer_id = c.id AND
s.is_active = TRUE AND
(s.ends_at IS NULL OR s.ends_at > NOW())
LEFT JOIN subscription_plans p ON s.plan_id = p.id
WHERE k.api_key = ?
");
$stmt->execute([$api_key]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$data) {
throw new Exception('Invalid API key', 403);
}
// Determine the effective tier (key override or subscription)
$data['effective_tier'] = $data['key_tier'] ?? $data['tier_name'] ?? 'free';
// Standard validations
if (!$data['is_active']) {
throw new Exception('API key disabled', 403);
}
if ($data['expires_at'] && strtotime($data['expires_at']) < time()) {
throw new Exception('API key expired', 403);
}
return $data;
};
}
public function get_create_tables_sql(): string {
return "-- Customers table
CREATE TABLE customers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Subscription plans table
CREATE TABLE subscription_plans (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- 'free', 'pro', 'enterprise'
monthly_price DECIMAL(10,2) NOT NULL,
rate_limit INT NOT NULL, -- requests per hour
burst_limit INT NOT NULL, -- requests per second
features JSON NOT NULL -- additional features
);
-- Customer subscriptions table
CREATE TABLE customer_subscriptions (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
plan_id INT NOT NULL,
starts_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ends_at TIMESTAMP NULL, -- NULL for ongoing subscriptions
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (customer_id) REFERENCES customers(id),
FOREIGN KEY (plan_id) REFERENCES subscription_plans(id)
);
CREATE TABLE customer_api_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
api_key VARCHAR(64) NOT NULL,
secret_key VARCHAR(64) NULL COMMENT 'Optional second factor for HMAC authentication',
key_name VARCHAR(100) NULL COMMENT 'Descriptive name for the key',
tier VARCHAR(20) NULL COMMENT 'Overrides customer subscription tier if set',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL COMMENT 'Optional expiration date',
revoked_at TIMESTAMP NULL,
ip_restrictions TEXT NULL COMMENT 'JSON array of allowed IPs/CIDR ranges',
referer_restrictions TEXT NULL COMMENT 'JSON array of allowed HTTP referers',
permissions TEXT NULL COMMENT 'JSON array of permissions/scopes',
rate_limit_override INT NULL COMMENT 'Custom rate limit (requests per hour)',
burst_limit_override INT NULL COMMENT 'Custom burst limit (requests per second)',
metadata JSON NULL COMMENT 'Additional key metadata',
UNIQUE INDEX idx_api_key (api_key),
INDEX idx_customer_id (customer_id),
INDEX idx_is_active (is_active),
INDEX idx_expires_at (expires_at),
FOREIGN KEY (customer_id)
REFERENCES customers(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE api_key_permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(50) NOT NULL,
description TEXT NULL,
UNIQUE INDEX idx_permission_key (permission_key)
);
CREATE TABLE api_key_permission_mapping (
api_key_id INT NOT NULL,
permission_id INT NOT NULL,
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
granted_by INT NULL COMMENT 'User who granted this permission',
PRIMARY KEY (api_key_id, permission_id),
FOREIGN KEY (api_key_id) REFERENCES customer_api_keys(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES api_key_permissions(id) ON DELETE CASCADE
);
CREATE TABLE api_rate_limits (
id INT AUTO_INCREMENT PRIMARY KEY,
api_key VARCHAR(64) NOT NULL,
request_count INT NOT NULL DEFAULT 0,
burst_tokens INT NOT NULL DEFAULT 10,
burst_last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_request TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (api_key) REFERENCES customer_api_keys(api_key),
INDEX (api_key)
);
";
}
}

@ -0,0 +1,155 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\apis\rate_limiting;
class rate_limiter {
private $db;
private $limit; // Requests per window (long-term)
private $window; // Window in seconds (long-term)
private $burst_limit; // Maximum burst capacity
private $burst_refill; // How many tokens refill per second
public function __construct(
PDO $db,
int $limit = 100,
int $window = 3600,
int $burst_limit = 5,
int $burst_refill = 1
) {
$this->db = $db;
$this->limit = $limit;
$this->window = $window;
$this->burst_limit = $burst_limit;
$this->burst_refill = $burst_refill;
}
public function check_rate_limit(string $apiKey): array {
$this->db->beginTransaction();
try {
$now = time();
$stmt = $this->db->prepare(
"SELECT * FROM api_rate_limits
WHERE api_key = ? FOR UPDATE");
$stmt->execute([$apiKey]);
$record = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$record) {
// First request - initialize with full burst capacity
$stmt = $this->db->prepare(
"INSERT INTO api_rate_limits
(api_key, request_count, last_request,
burst_tokens, burst_last_update)
VALUES (?, 1, FROM_UNIXTIME(?), ?, ?)");
$stmt->execute([
$apiKey,
$now,
$this->burst_limit - 1, // Start with full burst capacity
$now
]);
return [
'allowed' => true,
'remaining' => $this->limit - 1,
'burst_remaining' => $this->burst_limit - 1,
'reset' => $now + $this->window,
'burst_reset' => $now + ceil(1 / $this->burst_refill)
];
}
// Calculate burst token refill
$last_burst_update = strtotime($record['burst_last_update']);
$time_passed = $now - $last_burst_update;
$tokens_to_add = $time_passed * $this->burst_refill;
$burst_tokens = min(
$this->burst_limit,
$record['burst_tokens'] + $tokens_to_add
);
// Check burst limit first
if ($burst_tokens < 1) {
$this->db->commit();
return [
'allowed' => false,
'remaining' => $this->limit - $record['request_count'],
'burst_remaining' => 0,
'reset' => strtotime($record['last_request']) + $this->window,
'burst_reset' => $now + ceil((1 - $burst_tokens) / $this->burst_refill)
];
}
// Check long-term limit
$windowStart = $now - $this->window;
$lastRequest = strtotime($record['last_request']);
if ($lastRequest < $windowStart) {
// New time window, reset count
$stmt = $this->db->prepare(
"UPDATE api_rate_limits
SET request_count = 1,
last_request = FROM_UNIXTIME(?),
burst_tokens = ?,
burst_last_update = FROM_UNIXTIME(?)
WHERE api_key = ?");
$stmt->execute([
$now,
$burst_tokens - 1,
$now,
$apiKey
]);
$remaining = $this->limit - 1;
} else {
// Within current window
if ($record['request_count'] >= $this->limit) {
$this->db->commit();
return [
'allowed' => false,
'remaining' => 0,
'burst_remaining' => $burst_tokens,
'reset' => $lastRequest + $this->window,
'burst_reset' => $now + ceil((1 - $burst_tokens) / $this->burst_refill)
];
}
// Update counts
$stmt = $this->db->prepare(
"UPDATE api_rate_limits
SET request_count = request_count + 1,
last_request = FROM_UNIXTIME(?),
burst_tokens = ?,
burst_last_update = FROM_UNIXTIME(?)
WHERE api_key = ?");
$stmt->execute([
$now,
$burst_tokens - 1,
$now,
$apiKey
]);
$remaining = $this->limit - ($record['request_count'] + 1);
}
$this->db->commit();
return [
'allowed' => true,
'remaining' => $remaining,
'burst_remaining' => $burst_tokens - 1,
'reset' => $now + ($this->window - ($now - $windowStart)),
'burst_reset' => $now + ceil((1 - ($burst_tokens - 1)) / $this->burst_refill)
];
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
}
}

@ -0,0 +1,111 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\apis\rate_limiting;
class redis_rate_limiter {
private $redis;
private $limit;
private $window;
private $burst_limit;
private $burst_refill;
public function __construct(
\Redis $redis,
int $limit = 100,
int $window = 3600,
int $burst_limit = 5,
int $burst_refill = 1
) {
$this->redis = $redis;
$this->limit = $limit;
$this->window = $window;
$this->burst_limit = $burst_limit;
$this->burst_refill = $burst_refill;
}
public function check_rate_limit(string $api_key): array {
$now = time();
$long_term_key = "rate_limit:long:$api_key";
$burst_key = "rate_limit:burst:$api_key";
// Long-term rate limiting
$long_term = $this->redis->get($long_term_key);
if (!$long_term) {
$this->redis->setex($long_term_key, $this->window, 1);
$long_remaining = $this->limit - 1;
$long_reset = $now + $this->window;
} else {
$long_count = (int)$long_term;
if ($long_count >= $this->limit) {
$longTtl = $this->redis->ttl($long_term_key);
return [
'allowed' => false,
'remaining' => 0,
'burst_remaining' => 0,
'reset' => $now + $longTtl,
'burst_reset' => $now + $longTtl,
'limit_type' => 'long'
];
}
$this->redis->incr($long_term_key);
$long_remaining = $this->limit - ($long_count + 1);
$long_reset = $now + $this->redis->ttl($long_term_key);
}
// Burst rate limiting (token bucket)
$burst_data = $this->redis->hGetAll($burst_key);
if (empty($burst_data)) {
$burst_tokens = $this->burst_limit - 1;
$burst_last_update = $now;
$this->redis->hMSet($burst_key, [
'tokens' => $burst_tokens,
'last_update' => $now
]);
$this->redis->expire($burst_key, $this->window);
} else {
$burst_tokens = (int)$burst_data['tokens'];
$burst_last_update = (int)$burst_data['last_update'];
// Refill tokens based on time passed
$time_passed = $now - $burst_last_update;
$tokens_to_add = $time_passed * $this->burst_refill;
$burst_tokens = min(
$this->burst_limit,
$burst_tokens + $tokens_to_add
);
if ($burst_tokens < 1) {
return [
'allowed' => false,
'remaining' => $long_remaining,
'burst_remaining' => 0,
'reset' => $long_reset,
'burst_reset' => $now + ceil((1 - $burst_tokens) / $this->burst_refill),
'limit_type' => 'burst'
];
}
$burst_tokens--;
$this->redis->hMSet($burst_key, [
'tokens' => $burst_tokens,
'last_update' => $now
]);
$this->redis->expire($burst_key, $this->window);
}
return [
'allowed' => true,
'remaining' => $long_remaining,
'burst_remaining' => $burst_tokens,
'reset' => $long_reset,
'burst_reset' => $now + ceil((1 - $burst_tokens) / $this->burst_refill)
];
}
}

@ -0,0 +1,109 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\http;
class container {
protected array $bindings = [];
protected array $instances = [];
public function bind(string $abstract, $concrete = null, bool $shared = false): void
{
if ($concrete === null) {
$concrete = $abstract;
}
$this->bindings[$abstract] = [
'concrete' => $concrete,
'shared' => $shared
];
}
public function singleton(string $abstract, $concrete = null): void
{
$this->bind($abstract, $concrete, true);
}
public function make(string $abstract, array $parameters = [])
{
// Return if already resolved as singleton
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// Get the concrete implementation
$concrete = $this->bindings[$abstract]['concrete'] ?? $abstract;
// If it's a closure, resolve it
if ($concrete instanceof \Closure) {
$object = $concrete($this, $parameters);
} elseif (is_string($concrete)) {
// If it's a class name, instantiate it
$object = $this->build($concrete, $parameters);
} else { // Otherwise use as-is
$object = $concrete;
}
// Store if singleton
if (($this->bindings[$abstract]['shared'] ?? false) === true) {
$this->instances[$abstract] = $object;
}
return $object;
}
protected function build(string $class, array $parameters = [])
{
$reflector = new \ReflectionClass($class);
// Check if class is instantiable
if (!$reflector->isInstantiable()) {
throw new \Exception("Class {$class} is not instantiable");
}
// Get the constructor
$constructor = $reflector->getConstructor();
// If no constructor, instantiate without arguments
if ($constructor === null) {
return new $class();
}
// Get constructor parameters
$dependencies = $constructor->getParameters();
$instances = $this->resolve_dependencies($dependencies, $parameters);
return $reflector->newInstanceArgs($instances);
}
protected function resolve_dependencies(array $dependencies, array $parameters = [])
{
$results = [];
foreach ($dependencies as $dependency) {
// Check if parameter was provided
if (array_key_exists($dependency->name, $parameters)) {
$results[] = $parameters[$dependency->name];
continue;
}
// Get the type hinted class
$type = $dependency->getType();
if ($type && !$type->isBuiltin()) {
$results[] = $this->make($type->getName());
} elseif ($dependency->isDefaultValueAvailable()) {
$results[] = $dependency->getDefaultValue();
} else {
throw new \Exception("Cannot resolve dependency {$dependency->name}");
}
}
return $results;
}
}

@ -0,0 +1,100 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\http;
class kernel {
protected container $container;
protected array $middleware = [];
protected array $service_providers = [];
public function __construct() {
$this->container = new container();
$this->register_base_bindings();
}
protected function register_base_bindings(): void {
$this->container->singleton(container::class, fn() => $this->container);
$this->container->singleton(kernel::class, fn() => $this);
$this->container->bind(request::class, fn() => request::create_from_globals());
}
public function get_container(): container {
return $this->container;
}
public function register_service_provider(string $provider_class): void
{
$provider = new $provider_class($this);
$provider->register();
$this->service_providers[] = $provider;
}
public function add_middleware($middleware): void {
$this->middleware[] = $middleware;
}
public function handle(Request $request): Response {
try {
$response = $this->send_request_through_middleware($request);
return $response;
} catch (\Throwable $e) {
return $this->handle_exception($e);
}
}
protected function send_request_through_middleware(Request $request): Response
{
$middleware_stack = $this->build_middleware_stack();
// Create initial response
$response = new Response();
// Process the request through middleware
return $middleware_stack($request, $response);
}
protected function build_middleware_stack(): callable {
return array_reduce(
array_reverse($this->middleware),
function($next, $middleware) {
return function($request, $response) use ($next, $middleware) {
// Resolve middleware if it's a class name
if (is_string($middleware) && class_exists($middleware)) {
$middleware = $this->container->make($middleware);
}
if (is_callable($middleware)) {
return $middleware($request, $response, $next);
}
throw new \Exception("Invalid middleware");
};
},
function($request, $response) {
return $response;
}
);
}
protected function handle_exception(\Throwable $e): Response {
// Basic exception handling - override in child class
$response = new response();
return $response
->set_status_code(500)
->set_content('Server Error: ' . $e->getMessage());
}
public function run(): void
{
$request = $this->container->make(request::class);
$response = $this->handle($request);
$response->send();
}
}

@ -0,0 +1,67 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\http;
class request
{
protected array $query_params;
protected array $post_data;
protected array $server;
protected array $cookies;
protected array $files;
protected array $headers;
public function __construct(
array $query_params = [],
array $post_data = [],
array $server = [],
array $cookies = [],
array $files = [],
array $headers = []
) {
$this->query_params = $query_params;
$this->post_data = $post_data;
$this->server = $server;
$this->cookies = $cookies;
$this->files = $files;
$this->headers = $headers;
}
public static function create_from_globals(): self {
return new self(
$_GET,
$_POST,
$_SERVER,
$_COOKIE,
$_FILES,
getallheaders()
);
}
public function get_method(): string {
return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
}
public function get_uri(): string {
return $this->server['REQUEST_URI'] ?? '/';
}
public function get_query_params(): array {
return $this->query_params;
}
public function get_post_data(): array {
return $this->post_data;
}
public function get_header(string $name): ?string {
return $this->headers[$name] ?? null;
}
}

@ -0,0 +1,52 @@
<?php
declare(strict_types = 1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\http;
class response
{
protected string $content;
protected int $status_code;
protected array $headers;
public function __construct(
string $content = '',
int $status_code = 200,
array $headers = []
) {
$this->content = $content;
$this->status_code = $status_code;
$this->headers = $headers;
}
public function send(): void {
http_response_code($this->status_code);
foreach ($this->headers as $name => $value) {
header("$name: $value");
}
echo $this->content;
}
public function set_content(string $content): self {
$this->content = $content;
return $this;
}
public function set_status_code(int $code): self {
$this->status_code = $code;
return $this;
}
public function add_header(string $name, string $value): self {
$this->headers[$name] = $value;
return $this;
}
}

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* @author Robert Strutts <Bob_586@Yahoo.com>
* @copyright (c) 2025, Robert Strutts
* @license MIT
*/
namespace CodeHydrater\http;
abstract class service_provider {
protected kernel $kernel;
public function __construct(kernel $kernel)
{
$this->kernel = $kernel;
}
abstract public function register(): void;
}
/**
* Example Useage:
<?php
namespace App\Providers;
use CodeHydrater\http\kernel;
use CodeHydrater\http\service_provider;
use App\Services\Database;
use App\Services\Logger;
class app_service_provider extends service_provider
{
public function register(): void
{
$this->kernel->get_container()->singleton(Database::class, function() {
return new Database(
getenv('DB_HOST'),
getenv('DB_USER'),
getenv('DB_PASS'),
getenv('DB_NAME')
);
});
$this->kernel->get_container()->singleton(Logger::class, function() {
return new Logger(__DIR__ . '/../storage/logs/app.log');
});
}
}
*/
Loading…
Cancel
Save