<?php
|
namespace JVBase\rest;
|
|
use WP_REST_Request;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Fluent REST Route Builder
|
*
|
* Usage:
|
* Route::get('queue', [$this, 'getQueue'])->auth('user')->args(['status' => 'string']);
|
* Route::resource('content')->get(...)->post(...)->delete(false);
|
*/
|
class Route
|
{
|
private string $path;
|
private array $methods = [];
|
private array $currentMethod = [];
|
private bool $registered = false;
|
|
private static string $namespace = 'jvb/v1';
|
private static ?RateLimiter $rateLimiter = null;
|
|
/**
|
* Create a resource route (supports multiple methods)
|
*/
|
public static function resource(string $path): self
|
{
|
return new self($path);
|
}
|
|
/**
|
* Create a GET route
|
*/
|
public static function get(string $path, callable|array $callback): self
|
{
|
return (new self($path))->addMethod('GET', $callback);
|
}
|
|
/**
|
* Create a POST route
|
*/
|
public static function post(string $path, callable|array $callback): self
|
{
|
return (new self($path))->addMethod('POST', $callback);
|
}
|
|
/**
|
* Create a PUT route
|
*/
|
public static function put(string $path, callable|array $callback): self
|
{
|
return (new self($path))->addMethod('PUT', $callback);
|
}
|
|
/**
|
* Create a PATCH route
|
*/
|
public static function patch(string $path, callable|array $callback): self
|
{
|
return (new self($path))->addMethod('PATCH', $callback);
|
}
|
|
/**
|
* Create a DELETE route
|
*/
|
public static function delete(string $path, callable|array $callback): self
|
{
|
return (new self($path))->addMethod('DELETE', $callback);
|
}
|
|
/**
|
* Set custom namespace (defaults to 'jvb/v1')
|
*/
|
public static function setNamespace(string $namespace): void
|
{
|
self::$namespace = $namespace;
|
}
|
|
/**
|
* Get current namespace
|
*/
|
public static function getNamespace(): string
|
{
|
return self::$namespace;
|
}
|
|
private function __construct(string $path)
|
{
|
$this->path = '/' . ltrim($path, '/');
|
}
|
|
/**
|
* Add GET method to resource
|
*/
|
public function get(callable|array $callback): self
|
{
|
return $this->addMethod('GET', $callback);
|
}
|
|
/**
|
* Add POST method to resource
|
*/
|
public function post(callable|array $callback): self
|
{
|
return $this->addMethod('POST', $callback);
|
}
|
|
/**
|
* Add PUT method to resource
|
*/
|
public function put(callable|array $callback): self
|
{
|
return $this->addMethod('PUT', $callback);
|
}
|
|
/**
|
* Add PATCH method to resource
|
*/
|
public function patch(callable|array $callback): self
|
{
|
return $this->addMethod('PATCH', $callback);
|
}
|
|
/**
|
* Add DELETE method to resource (pass false to explicitly disable)
|
*/
|
public function delete(callable|array|false $callback): self
|
{
|
if ($callback === false) {
|
return $this; // Explicitly disabled
|
}
|
return $this->addMethod('DELETE', $callback);
|
}
|
|
/**
|
* Internal method to add HTTP method
|
*/
|
private function addMethod(string $method, callable|array $callback): self
|
{
|
// Finalize previous method if exists
|
if (!empty($this->currentMethod)) {
|
$this->methods[] = $this->currentMethod;
|
}
|
|
$this->currentMethod = [
|
'methods' => $method,
|
'callback' => $callback,
|
'permission_callback' => '__return_true',
|
'args' => [],
|
];
|
|
return $this;
|
}
|
|
/**
|
* Set authentication/permission requirement
|
*
|
* @param string|array|false $auth
|
* - 'public' or false: Anyone can access
|
* - 'user': Logged-in user must match 'user' param in request
|
* - 'logged_in': Any logged-in user
|
* - 'admin': Users with manage_options capability
|
* - 'verified': Users with skip_moderation capability
|
* - ['capability' => 'edit_posts']: Specific capability check
|
* - ['role' => 'artist']: Specific role check
|
* - ['roles' => ['artist', 'admin']]: Multiple roles (OR)
|
* - callable: Custom permission callback
|
*/
|
public function auth(string|array|callable|false $auth): self
|
{
|
if (empty($this->currentMethod)) {
|
return $this;
|
}
|
|
$this->currentMethod['permission_callback'] = match (true) {
|
$auth === false || $auth === 'public'
|
=> '__return_true',
|
|
$auth === 'logged_in'
|
=> 'is_user_logged_in',
|
|
$auth === 'user'
|
=> [PermissionHandler::class, 'userMatch'],
|
|
$auth === 'admin'
|
=> [PermissionHandler::class, 'isAdmin'],
|
|
$auth === 'verified'
|
=> [PermissionHandler::class, 'isVerified'],
|
|
is_callable($auth)
|
=> $auth,
|
|
is_array($auth) && isset($auth['capability'])
|
=> fn(WP_REST_Request $req) => current_user_can($auth['capability']),
|
|
is_array($auth) && isset($auth['role'])
|
=> fn(WP_REST_Request $req) => PermissionHandler::hasRole($req, $auth['role']),
|
|
is_array($auth) && isset($auth['roles'])
|
=> fn(WP_REST_Request $req) => PermissionHandler::hasAnyRole($req, $auth['roles']),
|
|
default
|
=> '__return_true',
|
};
|
|
return $this;
|
}
|
|
/**
|
* Add rate limiting to the route
|
*
|
* @param int $limit Maximum requests
|
* @param int $window Time window in seconds
|
*/
|
public function rateLimit(int $limit = 60, int $window = 60): self
|
{
|
if (empty($this->currentMethod)) {
|
return $this;
|
}
|
|
$originalCallback = $this->currentMethod['permission_callback'];
|
|
$this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $limit, $window) {
|
// Initialize rate limiter if needed
|
if (self::$rateLimiter === null) {
|
self::$rateLimiter = new RateLimiter();
|
}
|
|
// Check rate limit first
|
if (!self::$rateLimiter->checkLimit($request, $limit, $window)) {
|
return new WP_Error(
|
'rate_limit',
|
'Too many requests. Please wait before trying again.',
|
['status' => 429]
|
);
|
}
|
|
// Then check original permission
|
if ($originalCallback === '__return_true') {
|
return true;
|
}
|
|
if (is_callable($originalCallback)) {
|
return call_user_func($originalCallback, $request);
|
}
|
|
return true;
|
};
|
|
return $this;
|
}
|
|
/**
|
* Require nonce verification
|
*
|
* @param string $action Nonce action name (default: 'wp_rest')
|
* @param string $header Header name containing nonce (default: 'X-WP-Nonce')
|
*/
|
public function nonce(string $action = 'wp_rest', string $header = 'X-WP-Nonce'): self
|
{
|
if (empty($this->currentMethod)) {
|
return $this;
|
}
|
|
$originalCallback = $this->currentMethod['permission_callback'];
|
|
$this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $action, $header) {
|
$nonce = $request->get_header($header);
|
|
if (!wp_verify_nonce($nonce, $action)) {
|
return new WP_Error(
|
'invalid_nonce',
|
'Invalid or expired security token',
|
['status' => 403]
|
);
|
}
|
|
// Then check original permission
|
if ($originalCallback === '__return_true') {
|
return true;
|
}
|
|
if (is_callable($originalCallback)) {
|
return call_user_func($originalCallback, $request);
|
}
|
|
return true;
|
};
|
|
return $this;
|
}
|
|
/**
|
* Define route arguments with shorthand syntax
|
*
|
* @param array $args Argument definitions
|
* Shorthand: ['name' => 'type|required|default:value|enum:a,b,c']
|
* Full: ['name' => ['type' => 'string', 'required' => true, ...]]
|
*
|
* Examples:
|
* 'status' => 'string'
|
* 'status' => 'string|required'
|
* 'status' => 'string|default:all'
|
* 'status' => 'string|enum:pending,completed,failed'
|
* 'limit' => 'integer|default:50|min:1|max:100'
|
* 'ids' => 'array|required'
|
*/
|
public function args(array $args): self
|
{
|
if (empty($this->currentMethod)) {
|
return $this;
|
}
|
|
foreach ($args as $name => $definition) {
|
$this->currentMethod['args'][$name] = $this->parseArgDefinition($definition);
|
}
|
|
return $this;
|
}
|
|
/**
|
* Add a single argument
|
*/
|
public function arg(string $name, string|array $definition): self
|
{
|
if (empty($this->currentMethod)) {
|
return $this;
|
}
|
|
$this->currentMethod['args'][$name] = $this->parseArgDefinition($definition);
|
return $this;
|
}
|
|
/**
|
* Parse shorthand argument definition into WP REST format
|
*/
|
private function parseArgDefinition(string|array $definition): array
|
{
|
// Already full format
|
if (is_array($definition)) {
|
return $definition;
|
}
|
|
$parts = explode('|', $definition);
|
$type = trim($parts[0]);
|
|
$arg = [
|
'type' => $type,
|
'required' => false,
|
];
|
|
// Add sanitize callback based on type
|
$arg['sanitize_callback'] = match ($type) {
|
'integer', 'int' => 'absint',
|
'string' => 'sanitize_text_field',
|
'email' => 'sanitize_email',
|
'url' => 'esc_url_raw',
|
'boolean', 'bool' => 'rest_sanitize_boolean',
|
default => null,
|
};
|
|
// Normalize type for WP
|
if ($type === 'int') {
|
$arg['type'] = 'integer';
|
} elseif ($type === 'bool') {
|
$arg['type'] = 'boolean';
|
}
|
|
// Parse modifiers
|
foreach (array_slice($parts, 1) as $part) {
|
$part = trim($part);
|
|
if ($part === 'required') {
|
$arg['required'] = true;
|
} elseif (str_starts_with($part, 'default:')) {
|
$value = substr($part, 8);
|
$arg['default'] = $this->castValue($value, $type);
|
} elseif (str_starts_with($part, 'enum:')) {
|
$arg['enum'] = array_map('trim', explode(',', substr($part, 5)));
|
} elseif (str_starts_with($part, 'min:')) {
|
$arg['minimum'] = (int) substr($part, 4);
|
} elseif (str_starts_with($part, 'max:')) {
|
$arg['maximum'] = (int) substr($part, 4);
|
} elseif (str_starts_with($part, 'desc:')) {
|
$arg['description'] = substr($part, 5);
|
} elseif (str_starts_with($part, 'pattern:')) {
|
$arg['pattern'] = substr($part, 8);
|
}
|
}
|
|
// Remove null sanitize callback
|
if ($arg['sanitize_callback'] === null) {
|
unset($arg['sanitize_callback']);
|
}
|
|
return $arg;
|
}
|
|
/**
|
* Cast value to appropriate type
|
*/
|
private function castValue(string $value, string $type): mixed
|
{
|
return match ($type) {
|
'integer', 'int' => (int) $value,
|
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
'number' => (float) $value,
|
'array' => explode(',', $value),
|
default => $value,
|
};
|
}
|
|
/**
|
* Register the route with WordPress
|
*/
|
public function register(): self
|
{
|
if ($this->registered) {
|
return $this;
|
}
|
|
// Add current method if not empty
|
if (!empty($this->currentMethod)) {
|
$this->methods[] = $this->currentMethod;
|
$this->currentMethod = [];
|
}
|
|
if (empty($this->methods)) {
|
return $this;
|
}
|
|
// Register single method or array of methods
|
$config = count($this->methods) === 1 ? $this->methods[0] : $this->methods;
|
|
register_rest_route(self::$namespace, $this->path, $config);
|
|
$this->registered = true;
|
|
return $this;
|
}
|
|
/**
|
* Auto-register on destruction
|
*/
|
public function __destruct()
|
{
|
if (!$this->registered && !empty($this->methods) || !empty($this->currentMethod)) {
|
$this->register();
|
}
|
}
|
|
/**
|
* Convert WordPress route pattern to more readable format
|
* Converts: queue/{id} to queue/(?P<id>[a-zA-Z0-9_-]+)
|
*/
|
public static function pattern(string $path, array $patterns = []): string
|
{
|
$defaults = [
|
'id' => '[a-zA-Z0-9_-]+',
|
'slug' => '[a-zA-Z0-9_-]+',
|
'type' => '[a-zA-Z_]+',
|
'int' => '[0-9]+',
|
];
|
|
$patterns = array_merge($defaults, $patterns);
|
|
return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) {
|
$name = $matches[1];
|
$type = $matches[2] ?? $name;
|
$pattern = $patterns[$type] ?? $patterns['id'];
|
return "(?P<{$name}>{$pattern})";
|
}, $path);
|
}
|
}
|