<?php
|
namespace JVBase\rest;
|
|
use WP_REST_Request;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Fluent REST Route Builder
|
*
|
* Usage:
|
* // Single-method routes
|
* Route::for('queue')->get([$this, 'getQueue'])->auth('user');
|
* Route::for('content')->post([$this, 'create'])->auth('verified');
|
*
|
* // Multi-method resources
|
* Route::for('uploads')
|
* ->get([$this, 'list'])->auth('user')
|
* ->post([$this, 'upload'])->auth('user')
|
* ->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 ?RateLimits $rateLimiter = null;
|
|
// =========================================================================
|
// ENTRY POINTS
|
// =========================================================================
|
|
/**
|
* Create a new route builder for the given path
|
*/
|
public static function for(string $path): self
|
{
|
return new self($path);
|
}
|
|
/**
|
* 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;
|
}
|
|
/**
|
* Convert readable pattern to WordPress regex
|
* Example: 'queue/{id}' becomes '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);
|
}
|
|
private function __construct(string $path)
|
{
|
$this->path = '/' . ltrim($path, '/');
|
}
|
|
// =========================================================================
|
// HTTP METHODS
|
// =========================================================================
|
|
/**
|
* Add GET handler
|
*/
|
public function get(callable|array $callback): self
|
{
|
return $this->addMethod('GET', $callback);
|
}
|
|
/**
|
* Add POST handler
|
*/
|
public function post(callable|array $callback): self
|
{
|
return $this->addMethod('POST', $callback);
|
}
|
|
/**
|
* Add PUT handler
|
*/
|
public function put(callable|array $callback): self
|
{
|
return $this->addMethod('PUT', $callback);
|
}
|
|
/**
|
* Add PATCH handler
|
*/
|
public function patch(callable|array $callback): self
|
{
|
return $this->addMethod('PATCH', $callback);
|
}
|
|
/**
|
* Add DELETE handler (pass false to explicitly disable)
|
*/
|
public function delete(callable|array|false $callback): self
|
{
|
if ($callback === false) {
|
return $this;
|
}
|
return $this->addMethod('DELETE', $callback);
|
}
|
|
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;
|
}
|
|
// =========================================================================
|
// AUTHENTICATION
|
// =========================================================================
|
|
/**
|
* Set authentication requirement
|
*
|
* @param string|array|callable|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
|
* - ['role' => 'artist']: Specific role
|
* - ['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'],
|
$auth === 'nonce' => [PermissionHandler::class, 'nonce'],
|
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
|
*/
|
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) {
|
if (self::$rateLimiter === null) {
|
self::$rateLimiter = new RateLimits();
|
}
|
|
if (!self::$rateLimiter->checkLimit($request, $limit, $window)) {
|
return new WP_Error(
|
'rate_limit',
|
'Too many requests. Please wait before trying again.',
|
['status' => 429]
|
);
|
}
|
|
if ($originalCallback === '__return_true') {
|
return true;
|
}
|
|
return is_callable($originalCallback)
|
? call_user_func($originalCallback, $request)
|
: true;
|
};
|
|
return $this;
|
}
|
|
/**
|
* Require nonce verification
|
*/
|
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]
|
);
|
}
|
|
if ($originalCallback === '__return_true') {
|
return true;
|
}
|
|
return is_callable($originalCallback)
|
? call_user_func($originalCallback, $request)
|
: true;
|
};
|
|
return $this;
|
}
|
|
// =========================================================================
|
// ARGUMENTS
|
// =========================================================================
|
|
/**
|
* Define route arguments with shorthand syntax
|
*
|
* Examples:
|
* 'status' => 'string'
|
* 'status' => 'string|required'
|
* 'status' => 'string|default:all'
|
* 'status' => 'string|enum:pending,completed,failed'
|
* 'limit' => 'integer|default:50|min:1|max:100'
|
*/
|
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;
|
}
|
|
private function parseArgDefinition(string|array $definition): array
|
{
|
if (is_array($definition)) {
|
return $definition;
|
}
|
|
$parts = explode('|', $definition);
|
$type = trim($parts[0]);
|
|
$arg = [
|
'type' => $type === 'int' ? 'integer' : ($type === 'bool' ? 'boolean' : $type),
|
'required' => false,
|
];
|
|
// 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',
|
'array' => function($value) {
|
if (is_array($value)) {
|
return $value;
|
}
|
return [];
|
},
|
'object' => function($value) {
|
if (is_array($value) || is_object($value)) {
|
return (array) $value;
|
}
|
return [];
|
},
|
default => null,
|
};
|
|
// Add validate callback for array/object types
|
if (in_array($type, ['array', 'object'])) {
|
$arg['validate_callback'] = function($value, $request, $param) {
|
// Allow empty arrays/objects
|
if (empty($value)) {
|
return true;
|
}
|
// Ensure it's an array or object
|
return is_array($value) || is_object($value);
|
};
|
}
|
|
// Parse modifiers
|
foreach (array_slice($parts, 1) as $part) {
|
$part = trim($part);
|
|
match (true) {
|
$part === 'required' => $arg['required'] = true,
|
str_starts_with($part, 'default:') => $arg['default'] = $this->castValue(substr($part, 8), $type),
|
str_starts_with($part, 'enum:') => $arg['enum'] = array_map('trim', explode(',', substr($part, 5))),
|
str_starts_with($part, 'min:') => $arg['minimum'] = (int) substr($part, 4),
|
str_starts_with($part, 'max:') => $arg['maximum'] = (int) substr($part, 4),
|
str_starts_with($part, 'desc:') => $arg['description'] = substr($part, 5),
|
str_starts_with($part, 'pattern:') => $arg['pattern'] = substr($part, 8),
|
default => null,
|
};
|
}
|
|
if ($arg['sanitize_callback'] === null) {
|
unset($arg['sanitize_callback']);
|
}
|
|
return $arg;
|
}
|
|
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,
|
};
|
}
|
|
// =========================================================================
|
// REGISTRATION
|
// =========================================================================
|
|
/**
|
* Register the route with WordPress
|
*/
|
public function register(): self
|
{
|
if ($this->registered) {
|
return $this;
|
}
|
|
if (!empty($this->currentMethod)) {
|
$this->methods[] = $this->currentMethod;
|
$this->currentMethod = [];
|
}
|
|
if (empty($this->methods)) {
|
return $this;
|
}
|
|
$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();
|
}
|
}
|
}
|