| | |
| | | * Fluent REST Route Builder |
| | | * |
| | | * Usage: |
| | | * Route::get('queue', [$this, 'getQueue'])->auth('user')->args(['status' => 'string']); |
| | | * Route::resource('content')->get(...)->post(...)->delete(false); |
| | | * // 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 bool $registered = false; |
| | | |
| | | private static string $namespace = 'jvb/v1'; |
| | | private static ?RateLimiter $rateLimiter = null; |
| | | private static ?RateLimits $rateLimiter = null; |
| | | |
| | | // ========================================================================= |
| | | // ENTRY POINTS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Create a resource route (supports multiple methods) |
| | | * Create a new route builder for the given path |
| | | */ |
| | | public static function resource(string $path): self |
| | | public static function for(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 |
| | |
| | | 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 method to resource |
| | | * Add GET handler |
| | | */ |
| | | public function get(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add POST method to resource |
| | | * Add POST handler |
| | | */ |
| | | public function post(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add PUT method to resource |
| | | * Add PUT handler |
| | | */ |
| | | public function put(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add PATCH method to resource |
| | | * Add PATCH handler |
| | | */ |
| | | public function patch(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add DELETE method to resource (pass false to explicitly disable) |
| | | * Add DELETE handler (pass false to explicitly disable) |
| | | */ |
| | | public function delete(callable|array|false $callback): self |
| | | { |
| | | if ($callback === false) { |
| | | return $this; // Explicitly disabled |
| | | return $this; |
| | | } |
| | | 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 |
| | |
| | | return $this; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // AUTHENTICATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Set authentication/permission requirement |
| | | * Set authentication requirement |
| | | * |
| | | * @param string|array|false $auth |
| | | * @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 check |
| | | * - ['role' => 'artist']: Specific role check |
| | | * - ['capability' => 'edit_posts']: Specific capability |
| | | * - ['role' => 'artist']: Specific role |
| | | * - ['roles' => ['artist', 'admin']]: Multiple roles (OR) |
| | | * - callable: Custom permission callback |
| | | */ |
| | |
| | | $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', |
| | | }; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add rate limiting to the route |
| | | * |
| | | * @param int $limit Maximum requests |
| | | * @param int $window Time window in seconds |
| | | * Add rate limiting |
| | | */ |
| | | public function rateLimit(int $limit = 60, int $window = 60): self |
| | | { |
| | |
| | | $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(); |
| | | self::$rateLimiter = new RateLimits(); |
| | | } |
| | | |
| | | // Check rate limit first |
| | | if (!self::$rateLimiter->checkLimit($request, $limit, $window)) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | return is_callable($originalCallback) |
| | | ? call_user_func($originalCallback, $request) |
| | | : 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 |
| | | { |
| | |
| | | |
| | | $this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $action, $header) { |
| | | $nonce = $request->get_header($header); |
| | | |
| | | error_log('[Route] Validating nonce....'); |
| | | error_log('Nonce: '.print_r($nonce, true)); |
| | | error_log('Action: '.print_r($action, true)); |
| | | if (!wp_verify_nonce($nonce, $action)) { |
| | | return new WP_Error( |
| | | 'invalid_nonce', |
| | |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | return is_callable($originalCallback) |
| | | ? call_user_func($originalCallback, $request) |
| | | : true; |
| | | }; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // ARGUMENTS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * 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 |
| | | { |
| | |
| | | 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; |
| | | } |
| | |
| | | $type = trim($parts[0]); |
| | | |
| | | $arg = [ |
| | | 'type' => $type, |
| | | 'type' => $type === 'int' ? 'integer' : ($type === 'bool' ? 'boolean' : $type), |
| | | 'required' => false, |
| | | ]; |
| | | |
| | | // Add sanitize callback based on type |
| | | // 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, |
| | | }; |
| | | |
| | | // Normalize type for WP |
| | | if ($type === 'int') { |
| | | $arg['type'] = 'integer'; |
| | | } elseif ($type === 'bool') { |
| | | $arg['type'] = 'boolean'; |
| | | // 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); |
| | | |
| | | 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); |
| | | } |
| | | 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, |
| | | }; |
| | | } |
| | | |
| | | // 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) { |
| | |
| | | }; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // REGISTRATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Register the route with WordPress |
| | | */ |
| | |
| | | return $this; |
| | | } |
| | | |
| | | // Add current method if not empty |
| | | if (!empty($this->currentMethod)) { |
| | | $this->methods[] = $this->currentMethod; |
| | | $this->currentMethod = []; |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | */ |
| | | public function __destruct() |
| | | { |
| | | if (!$this->registered && !empty($this->methods) || !empty($this->currentMethod)) { |
| | | 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); |
| | | } |
| | | } |