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[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); } }