`; the * callable is expected to return the success / error envelope * array. * * - `permissionCallback` is the permission callable invoked with * the WP_REST_Request; returns bool. * * Validation failures raise {@see InvalidArgumentException}; the * messages are developer-facing and never rendered to HTML. * * @package Affilun\Presentation\Rest\Routing */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Routing; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use InvalidArgumentException; /** * Immutable per-route registration metadata. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §2.1 / §3 / §19.1 and the per-field * validation rules. */ final class RouteDefinition { /** * §14.1 / WordPress HTTP method: read. */ public const METHOD_GET = 'GET'; /** * §14.1 / WordPress HTTP method: create. */ public const METHOD_POST = 'POST'; /** * §14.1 / WordPress HTTP method: partial update. */ public const METHOD_PATCH = 'PATCH'; /** * §14.1 / WordPress HTTP method: delete. */ public const METHOD_DELETE = 'DELETE'; /** * Closed set of HTTP methods recognized by this slice. * * Held as a private constant so the constructor and * {@see self::validMethods()} share the same source of truth. * * @var list */ private const VALID_METHODS = array( self::METHOD_GET, self::METHOD_POST, self::METHOD_PATCH, self::METHOD_DELETE, ); /** * Closed set of write-side HTTP methods. * * @var list */ private const WRITE_METHODS = array( self::METHOD_POST, self::METHOD_PATCH, self::METHOD_DELETE, ); /** * §2.1 fully-joined namespace string (e.g. `'affilun/v1'`). */ private string $namespace; /** * Route path beginning with `/` (e.g. `'/products/search'`). */ private string $route; /** * HTTP method; one of {@see self::VALID_METHODS}. */ private string $method; /** * §3 route-token consumed by * {@see \Affilun\Presentation\Rest\Permission\CapabilityPolicy}. */ private string $routeToken; /** * Whether this route is a write-side route (POST / PATCH / * DELETE). */ private bool $isWrite; /** * WordPress `register_rest_route()` `args` array. * * @var array */ private array $args; /** * Controller callback invoked with the WP_REST_Request. * * @var callable */ private $callback; /** * Permission callback invoked with the WP_REST_Request. * * @var callable */ private $permissionCallback; /** * Construct a route definition with explicit values for every * registration field. * * @param string $namespace §2.1 namespace string. * @param string $route Route path beginning with `/`. * @param string $method HTTP method; one of {@see self::VALID_METHODS}. * @param string $routeToken §3 route-token for capability resolution. * @param array $args `register_rest_route()` args. * @param callable $callback Controller callback. * @param callable $permissionCallback Permission callback. * * @throws InvalidArgumentException When any field violates the * per-field validation rules. */ public function __construct( // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- "namespace" is the canonical REST domain term per spec §2.1; the parameter name mirrors WordPress's own register_rest_route() signature. string $namespace, string $route, string $method, string $routeToken, array $args, callable $callback, callable $permissionCallback ) { if ( trim( $namespace ) === '' ) { $message = 'RouteDefinition namespace must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $route === '' || $route[0] !== '/' ) { $message = sprintf( 'RouteDefinition route must be a non-empty string starting with "/"; got "%s".', $route ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! in_array( $method, self::VALID_METHODS, true ) ) { $message = sprintf( 'RouteDefinition method must be one of [%s]; got "%s".', implode( ', ', self::VALID_METHODS ), $method ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( trim( $routeToken ) === '' ) { $message = 'RouteDefinition routeToken must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->namespace = $namespace; $this->route = $route; $this->method = $method; $this->routeToken = $routeToken; $this->isWrite = in_array( $method, self::WRITE_METHODS, true ); $this->args = $args; $this->callback = $callback; $this->permissionCallback = self::withCapabilityPermission( $routeToken, $this->isWrite, $permissionCallback ); } /** * §2.1 fully-joined namespace string. */ public function namespace(): string { return $this->namespace; } /** * Route path beginning with `/`. */ public function route(): string { return $this->route; } /** * HTTP method; one of {@see self::VALID_METHODS}. */ public function method(): string { return $this->method; } /** * §3 route-token consumed by the capability policy. */ public function routeToken(): string { return $this->routeToken; } /** * Report whether this route is a write-side route. */ public function isWrite(): bool { return $this->isWrite; } /** * WordPress `register_rest_route()` `args` array. * * @return array */ public function args(): array { return $this->args; } /** * Controller callback invoked with the WP_REST_Request. */ public function callback(): callable { return $this->callback; } /** * Permission callback invoked with the WP_REST_Request. */ public function permissionCallback(): callable { return $this->permissionCallback; } /** * Closed set of HTTP methods recognized by this slice. * * @return list */ public static function validMethods(): array { return self::VALID_METHODS; } /** * Wrap route-specific permission logic with WordPress capability checks. * * @param string $routeToken Capability-policy route token. * @param bool $isWrite Whether the route mutates state. * @param callable $permissionCallback Route-specific permission callback. */ private static function withCapabilityPermission( string $routeToken, bool $isWrite, callable $permissionCallback ): callable { return static function ( $request = null ) use ( $routeToken, $isWrite, $permissionCallback ): bool { if ( ! (bool) $permissionCallback( $request ) ) { return false; } $required = $isWrite ? CapabilityPolicy::requiredForWrite( $routeToken ) : CapabilityPolicy::requiredForRead( $routeToken ); if ( $required === array() ) { return true; } if ( ! function_exists( 'current_user_can' ) ) { return false; } foreach ( $required as $capability ) { if ( current_user_can( $capability ) ) { return true; } } return false; }; } /** * Return the route registration metadata in the shape * WordPress's `register_rest_route()` consumes. * * @return array */ public function toArgsArray(): array { return array( 'methods' => $this->method, 'callback' => $this->callback, 'permission_callback' => $this->permissionCallback, 'args' => $this->args, ); } }