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[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', default => null, }; // 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(); } } }