middleware(['can:manage_options'])->group(fn () => ...); * Route::prefix('generator')->capability('upload_files')->middleware([RoleGate::class])->group(...); * * prefix()/middleware()/capability() are resolved through __callStatic/__call so * the same names work as the static entry point and as instance chain links. * * middleware()/capability()/withPolicy() also chain off a route definition and * then apply to that route (or to every route of a resource()): * * Route::get('/reports', [ReportController::class, 'index'])->capability('manage_options'); * Route::resource(ItemController::class)->withPolicy(ItemPolicy::class); * * @method static static prefix(string $prefix) * @method static static middleware(string|array $middleware) * @method static static capability(string $capability) */ class Route { private static $routes = []; private static $prefix = ''; private static $middleware = []; private static $namespace = ''; private static $lastRoute = null; /** name => ['method' => ..., 'endpoint' => ...] for URL generation. */ private static $named = []; private $pendingPrefix = ''; private $pendingMiddleware = []; /** Indexes into self::$routes this instance was returned for (route-level chaining). */ private $routeIndexes = []; public static function __callStatic($method, $arguments) { return (new static())->__call($method, $arguments); } public function __call($method, $arguments) { switch ($method) { case 'prefix': $prefix = trim((string) ($arguments[0] ?? ''), '/'); if ($prefix !== '') { $this->pendingPrefix .= '/' . $prefix; } return $this; case 'middleware': $middleware = $arguments[0] ?? []; return $this->addMiddleware(is_array($middleware) ? $middleware : [$middleware]); case 'capability': // Sugar for middleware('can:{capability}'). return $this->addMiddleware(['can:' . (string) ($arguments[0] ?? '')]); } throw new \BadMethodCallException( 'Call to undefined method ' . esc_html(static::class . '::' . $method) . '()' ); } /** * Chained off a route definition, middleware lands on that route (all of * them for a resource); otherwise it is queued for the next group(). */ private function addMiddleware(array $middleware) { if (empty($this->routeIndexes)) { $this->pendingMiddleware = array_merge($this->pendingMiddleware, $middleware); return $this; } foreach ($this->routeIndexes as $index) { self::$routes[$index]['middleware'] = array_merge(self::$routes[$index]['middleware'], $middleware); } return $this; } public function group(callable $callback) { $previousPrefix = self::$prefix; $previousMiddleware = self::$middleware; self::$prefix = $previousPrefix . $this->pendingPrefix; self::$middleware = array_merge($previousMiddleware, $this->pendingMiddleware); $callback(); self::$prefix = $previousPrefix; self::$middleware = $previousMiddleware; return $this; } public static function get($endpoint, $controller) { return self::addRoute('GET', $endpoint, $controller); } public static function post($endpoint, $controller) { return self::addRoute('POST', $endpoint, $controller); } public static function put($endpoint, $controller) { return self::addRoute('PUT', $endpoint, $controller); } public static function patch($endpoint, $controller) { return self::addRoute('PATCH', $endpoint, $controller); } public static function delete($endpoint, $controller) { return self::addRoute('DELETE', $endpoint, $controller); } private static function addRoute($method, $endpoint, $controller, $params = []) { $endpoint = self::$prefix . '/' . trim($endpoint, '/'); if (is_array($controller)) { $handler = $controller[0]; $action = $controller[1]; } else { $handler = $controller; $action = 'index'; } if (self::$namespace) { $handler = self::$namespace . '\\' . $handler; } $parts = explode('/', $endpoint); $pattern = []; $parameters = []; foreach ($parts as $part) { if (strpos($part, '{') === 0 && strpos($part, '}') === strlen($part) - 1) { $paramName = trim($part, '{}'); $parameterPattern = isset($params[$paramName]) ? $params[$paramName] : '(?P<' . $paramName . '>[^/]+)'; $pattern[] = $parameterPattern; $parameters[] = $paramName; } else { $pattern[] = $part; } } $route = [ 'method' => $method, 'endpoint' => $endpoint, 'controller' => $handler, 'action' => $action, 'middleware' => self::$middleware, 'parameters' => $parameters, 'pattern' => implode('/', $pattern) ]; self::$routes[] = $route; $index = count(self::$routes) - 1; self::$lastRoute = &self::$routes[$index]; $instance = new static(); $instance->routeIndexes = [$index]; return $instance; } public static function resource($controller) { $definitions = [ self::get('', $controller), self::get('{id}', [$controller, 'show']), self::post('', [$controller, 'store']), self::put('{id}', [$controller, 'update']), self::delete('{id}', [$controller, 'destroy']), ]; // Chained calls (middleware, withPolicy, name) apply to all five routes. $instance = new static(); foreach ($definitions as $definition) { $instance->routeIndexes[] = $definition->routeIndexes[0]; } return $instance; } public static function registerRoutes($namespace = null) { if ($namespace === null) { $namespace = defined('SOFTMINAL_CACHE_API_NAMESPACE') ? SOFTMINAL_CACHE_API_NAMESPACE : ''; } if (empty($namespace) || empty(self::$routes)) { return; } foreach (self::$routes as $route) { $endpoint = $route['endpoint']; $method = $route['method']; if (!empty($route['parameters'])) { $pattern = isset($route['pattern']) ? $route['pattern'] : $endpoint; } else { $pattern = $endpoint; } register_rest_route($namespace, $pattern, [ 'methods' => $method, 'callback' => function($request) use ($route) { try { // Resolve through the container so constructor // dependencies are auto-wired; falls back to `new`. $controller = class_exists(\SoftminalCache\Framework\Foundation\Application::class) ? \SoftminalCache\Framework\Foundation\Application::getInstance()->make($route['controller']) : new $route['controller'](); $action = $route['action']; $params = $request->get_params(); return $controller->$action($request, $params); } catch (\SoftminalCache\Framework\Validation\ValidationException $e) { // Rule-based validation failures become a structured 422. return $e->toWpError(); } catch (\Throwable $e) { // The message goes to the log, not to the caller: upstream API // bodies, table names and paths are not for anonymous clients. \SoftminalCache\Framework\Support\Logger::channel('router')->exception($e, 'error', [ 'method' => $route['method'] ?? '', 'endpoint' => $route['endpoint'] ?? '', ]); do_action('softminal_cache_route_exception', $e, $route); // Exposed only when debugging, or when the plugin opts // in through the filter. $expose = (bool) apply_filters( 'softminal_cache_expose_exception_messages', defined('WP_DEBUG') && WP_DEBUG, $e, $route ); return new \WP_Error( 'api_error', $expose ? $e->getMessage() : 'Something went wrong. Please try again later.', ['status' => 500] ); } }, 'permission_callback' => function($request) use ($route) { return self::handleMiddleware($request, $route); } ]); } } private static function handleMiddleware($request, $route) { $middlewares = $route['middleware'] ?? []; $isPublic = in_array('public', $middlewares, true); // Only middleware that AUTHENTICATES the caller (implements the Authenticates // contract: JWT, service token…) may replace the WP session check. A rate // limiter, a role gate, a closure or an unknown class name never does. // Every 'can:capability' must pass, so group-level and route-level // capabilities stack. $hasClassMiddleware = false; $capabilities = []; foreach ($middlewares as $mw) { if ($mw === 'public') { continue; } if (is_string($mw) && strpos($mw, 'can:') === 0) { $capabilities[] = substr($mw, 4); continue; } $authenticates = is_object($mw) ? $mw instanceof \SoftminalCache\Framework\Contracts\Authenticates : (is_string($mw) && class_exists($mw) && is_subclass_of($mw, \SoftminalCache\Framework\Contracts\Authenticates::class)); if ($authenticates) { $hasClassMiddleware = true; } } $hasExplicitCan = !empty($capabilities); if (!$hasExplicitCan) { // Routes with no explicit capability are admin-only unless the // plugin lowers the default through this filter. $capabilities = [(string) apply_filters('softminal_cache_default_route_capability', 'manage_options', $route)]; } // WordPress session + capability check. Skipped for public routes, and // for routes whose auth is fully delegated to an Authenticates middleware // (no explicit 'can:'). An explicit 'can:' is ALWAYS enforced, so // ['can:upload_files', JwtAuth::class] means both must pass. if ($hasExplicitCan || (!$isPublic && !$hasClassMiddleware)) { if (!is_user_logged_in()) { return new WP_Error( 'rest_unauthorized', 'You must be logged in.', ['status' => 401] ); } foreach ($capabilities as $capability) { if ($capability === '' || !current_user_can($capability)) { return new WP_Error( 'rest_forbidden', 'Insufficient permissions.', ['status' => 403] ); } } } // Run custom middleware (skip built-in flags). Anything that cannot be // run rejects the request: a mistyped or missing middleware must never // leave the route open. foreach ($middlewares as $middleware) { if ($middleware === 'public' || (is_string($middleware) && strpos($middleware, 'can:') === 0)) { continue; } try { if ($middleware instanceof \Closure) { $result = $middleware($request); } else { if (is_string($middleware) && !class_exists($middleware)) { return self::middlewareUnavailable($middleware, $route); } $instance = is_object($middleware) ? $middleware : (is_string($middleware) ? new $middleware() : null); if ($instance === null || !method_exists($instance, 'handle')) { return self::middlewareUnavailable($middleware, $route); } $result = $instance->handle($request); } if ($result instanceof WP_Error) { return $result; } if ($result !== true) { return new WP_Error( 'middleware_failed', 'Access denied', ['status' => 403] ); } } catch (\Throwable $e) { return new WP_Error( 'server_error', 'Internal server error', ['status' => 500] ); } } return true; } /** * Fail closed: a middleware that cannot run must not silently open the route. */ private static function middlewareUnavailable($middleware, $route = []) { \SoftminalCache\Framework\Support\Logger::channel('router')->error( 'route middleware missing or invalid: {middleware}', [ 'middleware' => is_string($middleware) ? $middleware : (is_object($middleware) ? get_class($middleware) : gettype($middleware)), 'endpoint' => $route['endpoint'] ?? '', ] ); do_action('softminal_cache_middleware_error', $middleware); return new WP_Error( 'middleware_missing', 'Internal server error', ['status' => 500] ); } public function withPolicy($policy) { if (!empty($this->routeIndexes)) { return $this->addMiddleware([$policy]); } if (self::$lastRoute) { self::$lastRoute['middleware'][] = $policy; } return $this; } /** * Assign a name to the route this was chained from, for URL generation. * On a resource() each route is named "{name}.{action}" (items.index, * items.show, items.store, items.update, items.destroy). */ public function name($name) { if (count($this->routeIndexes) > 1) { foreach ($this->routeIndexes as $index) { self::nameRoute($index, $name . '.' . self::$routes[$index]['action']); } return $this; } if (!empty($this->routeIndexes)) { self::nameRoute($this->routeIndexes[0], $name); } elseif (!empty(self::$routes)) { self::nameRoute(count(self::$routes) - 1, $name); } return $this; } private static function nameRoute($index, $name) { self::$routes[$index]['name'] = $name; self::$named[$name] = [ 'method' => self::$routes[$index]['method'], 'endpoint' => self::$routes[$index]['endpoint'], ]; } /** * Whether a named route exists. */ public static function has($name): bool { return isset(self::$named[$name]); } /** * Generate a full REST URL for a named route, substituting {param}s. * * @param string $name * @param array $params * @return string Empty string when the name is unknown. */ public static function route($name, array $params = []): string { if (!isset(self::$named[$name])) { return ''; } $endpoint = self::$named[$name]['endpoint']; foreach ($params as $key => $value) { $endpoint = str_replace('{' . $key . '}', rawurlencode((string) $value), $endpoint); } if (!defined('SOFTMINAL_CACHE_API_NAMESPACE')) { return ''; } $namespace = SOFTMINAL_CACHE_API_NAMESPACE; // Any unreplaced {param} placeholders remain — the caller passed too few. return rest_url(trim($namespace, '/') . '/' . ltrim($endpoint, '/')); } public static function getNamedRoutes(): array { return self::$named; } public static function getRegisteredRoutes() { return self::$routes; } }