Jake Vanderwerf
5 days ago a9b3b28d001941921aa70d37fdc87c758a163a44
inc/rest/Route.php
@@ -12,8 +12,15 @@
 * 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
{
@@ -23,57 +30,21 @@
   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
@@ -89,13 +60,40 @@
      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
   {
@@ -103,7 +101,7 @@
   }
   /**
    * Add POST method to resource
    * Add POST handler
    */
   public function post(callable|array $callback): self
   {
@@ -111,7 +109,7 @@
   }
   /**
    * Add PUT method to resource
    * Add PUT handler
    */
   public function put(callable|array $callback): self
   {
@@ -119,7 +117,7 @@
   }
   /**
    * Add PATCH method to resource
    * Add PATCH handler
    */
   public function patch(callable|array $callback): self
   {
@@ -127,19 +125,16 @@
   }
   /**
    * 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
@@ -157,17 +152,21 @@
      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
    */
@@ -180,31 +179,23 @@
      $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',
      };
@@ -213,10 +204,7 @@
   }
   /**
    * 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
   {
@@ -227,12 +215,10 @@
      $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',
@@ -241,16 +227,13 @@
            );
         }
         // 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;
@@ -258,9 +241,6 @@
   /**
    * 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
   {
@@ -272,7 +252,9 @@
      $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',
@@ -281,35 +263,31 @@
            );
         }
         // 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
   {
@@ -337,12 +315,8 @@
      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;
      }
@@ -351,50 +325,60 @@
      $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']);
      }
@@ -402,9 +386,6 @@
      return $arg;
   }
   /**
    * Cast value to appropriate type
    */
   private function castValue(string $value, string $type): mixed
   {
      return match ($type) {
@@ -416,6 +397,10 @@
      };
   }
   // =========================================================================
   // REGISTRATION
   // =========================================================================
   /**
    * Register the route with WordPress
    */
@@ -425,7 +410,6 @@
         return $this;
      }
      // Add current method if not empty
      if (!empty($this->currentMethod)) {
         $this->methods[] = $this->currentMethod;
         $this->currentMethod = [];
@@ -435,13 +419,10 @@
         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;
   }
@@ -450,31 +431,8 @@
    */
   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);
   }
}