int)`. The * {@see SyncServiceProvider} route-callback wrapper extracts * and strips the {@see self::META_HTTP_STATUS} key when stamping * the `WP_REST_Response` HTTP status; the key is never serialized * onto the wire response. * * Permission resolution flow (mirrors * {@see \Affilun\Presentation\Rest\Controller\EditorController}): * * 1. The endpoint names a stable route token * ({@see self::ROUTE_TOKEN_PRODUCT_SYNC} = `'sync.run'`) per * §3.2. * 2. {@see CapabilityPolicy::requiredForWrite()} yields the * OR-list of required caps (`affilun_run_sync` / * `manage_options`). * 3. The controller probes each required cap via the injected * `$capabilityProvider` callable, populates a * {@see RequestContext} caps map with the held tokens, and * delegates to {@see CapabilityPolicy::isAuthorized()}. * 4. A failed authorization check short-circuits to a 403 * `affilun_forbidden` error envelope; the §11.2 success path * runs otherwise. * * Exception mapping order in {@see self::mapException()}: * * 1. {@see ProductNotFoundException} (Application layer) → 404 * `affilun_product_not_found`. * 2. {@see \InvalidArgumentException} → 422 * `affilun_validation_failed` carrying the exception message in * `details.exception`. The §11.2 use case raises this for * a non-positive product id that bypassed the route validator. * 3. Catch-all {@see \Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. The original * exception's class and message are NOT forwarded so the §10.1 * "no internal detail leakage" posture is preserved. * * §11.2 success envelope shape: * * - HTTP status 202 (Accepted) — the job is queued, not yet * executed. * - `data` — `EnqueueProductSyncResult::toArray()` output: * `array('queued' => true, 'job' => array('id', 'job_type', * 'status'))` (with `status` projected to the §6.0 REST * publication value `'queued'`). * - `meta.request_id` — §4.1 request id (provided by the * {@see SuccessEnvelope}). * - `meta.correlation_id` — §4.1 correlation id (provided by * the {@see SuccessEnvelope} from inbound headers; preferred * when present). * - `meta.job_id` — convenience field carrying the persisted * `affilun_sync_jobs.id` so observability surfaces can deep-link * without parsing `data.job.id`. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Product\Exception\ProductNotFoundException; use Affilun\Application\Sync\UseCase\EnqueueProductSyncResult; use Affilun\Application\Sync\UseCase\EnqueueProductSyncUseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use Affilun\Presentation\Rest\RequestContext; use Affilun\Presentation\Rest\Response\ErrorEnvelope; use Affilun\Presentation\Rest\Response\ProviderExceptionMapper; use Affilun\Presentation\Rest\Response\SuccessEnvelope; use Affilun\Presentation\Rest\Validation\RequestValidator; use Affilun\Presentation\Rest\Validation\ValidationResult; use InvalidArgumentException; use Throwable; /** * REST controller for the §11.2 sync-enqueue endpoint. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, the exception mapping order, and the §11.2 * success envelope shape. * * ### Nonce verification (§3.1) * * Per spec §3.1, all admin / block-editor REST calls authenticate via * the WordPress logged-in cookie plus the canonical `wp_rest` nonce * header (`X-WP-Nonce`), validated automatically by the WordPress REST * framework. The REST `permission_callback` checks capabilities at * route level, and this controller repeats the same authorization * (§3.2 / §3.3) via the injected `CapabilityPolicy` + capability provider; nonce * remains the WP framework's responsibility. */ final class SyncController { /** * Stable route token for §11.2 * `POST /affilun/v1/sync/products/{id}`. Mapped by * {@see CapabilityPolicy} to the §3.2 `sync.run` write-side * OR-list (`affilun_run_sync` / `manage_options`). */ public const ROUTE_TOKEN_PRODUCT_SYNC = 'sync.run'; /** * Synthetic envelope key carrying the desired HTTP status. * * The {@see \Affilun\Presentation\Rest\SyncServiceProvider} * route-callback wrapper extracts and strips this key when * stamping the `WP_REST_Response` status; the key is never * serialized onto the wire response. */ public const META_HTTP_STATUS = '_http_status'; /** * §11.2 use case driving the enqueue. */ private EnqueueProductSyncUseCase $enqueueUseCase; /** * §15.2 provider exception → error envelope mapper. Held even * though §11.2 does not raise provider exceptions itself, so * the constructor matches the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * shape and so future §11.x slices that DO raise provider * errors (job-detail polling, retry-now) can reuse the * controller without a constructor change. */ private ProviderExceptionMapper $providerExceptionMapper; /** * §3.2 / §3.3 capability policy. */ private CapabilityPolicy $capabilityPolicy; /** * §17.1 request input validator. */ private RequestValidator $validator; /** * §3 request adapter (header / param / body extraction). */ private RestRequestAdapter $requestAdapter; /** * Resolver for the current WordPress user id. * * Defaults to a static closure that calls * {@see \get_current_user_id()} when available and returns null * otherwise (so unit tests run without booting WordPress). * * @var callable(): ?int */ private $userIdProvider; /** * Resolver for whether the current user holds a given capability. * * Defaults to a static closure that calls * {@see \current_user_can()} when available and returns false * otherwise (so unit tests run without booting WordPress). * * @var callable(string): bool */ private $capabilityProvider; /** * Construct the controller with explicit dependency injection. * * The optional `$userIdProvider` / `$capabilityProvider` * callables follow the established * {@see \Affilun\Presentation\Rest\Controller\EditorController} * pattern: when null they fall back to WordPress-aware static * closures that gracefully degrade to "anonymous / no caps" * when the WordPress runtime is not loaded (the unit-test * harness). * * @param EnqueueProductSyncUseCase $enqueueUseCase §11.2 use case. * @param ProviderExceptionMapper $providerExceptionMapper §15.2 mapper. * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RequestValidator $validator §17.1 validator. * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param callable|null $userIdProvider `(): ?int` resolver, or null for WP default. * @param callable|null $capabilityProvider `(string): bool` resolver, or null for WP default. */ public function __construct( EnqueueProductSyncUseCase $enqueueUseCase, ProviderExceptionMapper $providerExceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $validator, RestRequestAdapter $requestAdapter, ?callable $userIdProvider = null, ?callable $capabilityProvider = null ) { $this->enqueueUseCase = $enqueueUseCase; $this->providerExceptionMapper = $providerExceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->validator = $validator; $this->requestAdapter = $requestAdapter; $this->userIdProvider = $userIdProvider ?? static function (): ?int { if ( ! function_exists( 'get_current_user_id' ) ) { return null; } $id = (int) get_current_user_id(); return $id !== 0 ? $id : null; }; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §11.2 `POST /affilun/v1/sync/products/{id}` dispatch. * * Validates the `id` route parameter (positive integer), reads * the optional `correlation_id` body field, authorizes the * caller against the §3.2 `sync.run` route token, and drives * {@see EnqueueProductSyncUseCase}. Renders the §11.2 success * envelope with HTTP 202 on success. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §11.2 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function enqueue( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_PRODUCT_SYNC ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $id = $this->requestAdapter->paramInt( $request, 'id' ); $result = ValidationResult::valid(); $result = $this->validator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } $correlationId = null; $body = $this->requestAdapter->jsonBody( $request ); if ( array_key_exists( 'correlation_id', $body ) ) { $rawCorr = $body['correlation_id']; if ( is_string( $rawCorr ) && trim( $rawCorr ) !== '' ) { $correlationId = trim( $rawCorr ); } } try { $enqueueResult = $this->enqueueUseCase->execute( (int) $id, $correlationId ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } return $this->successResponse( $enqueueResult, $ctx ); } /** * Build the per-request {@see RequestContext} carrying the * resolved user id and the currently-held subset of the * supplied required-capability list. * * @param mixed $request WP_REST_Request, or array shim. * @param list $candidateCaps Caps to probe and populate. */ private function buildContext( mixed $request, array $candidateCaps ): RequestContext { $userId = $this->currentUserId(); $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, $userId, $caps ); } /** * Build the §11.2 success envelope (HTTP 202 Accepted). * * Adds the convenience `meta.job_id` field carrying the * persisted `affilun_sync_jobs.id` so observability surfaces can * deep-link without parsing `data.job.id`. * * @param EnqueueProductSyncResult $result Result of the enqueue. * @param RequestContext $ctx Per-request meta. * * @return array */ private function successResponse( EnqueueProductSyncResult $result, RequestContext $ctx ): array { $envelope = SuccessEnvelope::fromContext( $result->toArray(), $ctx, array( 'job_id' => (int) $result->job()->id() ) ); return $envelope->toArray() + array( self::META_HTTP_STATUS => 202 ); } /** * Build a §4.3 error envelope + synthetic * {@see self::META_HTTP_STATUS}. * * @param string $code §15.1 code. * @param string $message Human-readable summary. * @param int $status §14.1 HTTP status. * @param RequestContext $ctx Per-request meta. * @param array $details Structured diagnostic context. * @param bool $retryable §10.2 retryability hint. * * @return array */ private function errorResponse( string $code, string $message, int $status, RequestContext $ctx, array $details = array(), bool $retryable = false ): array { $envelope = ErrorEnvelope::fromContext( $code, $message, $status, $ctx, $details, $retryable ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Build a §4.4 validation error envelope + synthetic * {@see self::META_HTTP_STATUS} pinned to 422. * * @param ValidationResult $result Accumulator carrying the * per-field errors. * @param RequestContext $ctx Per-request meta. * * @return array */ private function validationErrorResponse( ValidationResult $result, RequestContext $ctx ): array { $envelope = $result->toEnvelope( $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY ); } /** * Translate an arbitrary {@see Throwable} into a §4.3 envelope * + synthetic {@see self::META_HTTP_STATUS}. * * Mapping order: * * 1. {@see ProductNotFoundException} → 404. * 2. {@see ProviderException} → routed through * {@see ProviderExceptionMapper::map()}. * 3. {@see InvalidArgumentException} → 422 with * `details.exception = $e->getMessage()`. * 4. Other {@see Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. * * The {@see ProviderExceptionMapper} dependency is held for * symmetry with the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * shape; §11.2 itself is unlikely to raise provider exceptions * but future slices may. * * @param Throwable $e Exception to translate. * @param RequestContext $ctx Per-request meta. * * @return array */ private function mapException( Throwable $e, RequestContext $ctx ): array { if ( $e instanceof ProductNotFoundException ) { return $this->errorResponse( ErrorEnvelope::CODE_PRODUCT_NOT_FOUND, $e->getMessage() !== '' ? $e->getMessage() : 'Product not found.', ErrorEnvelope::STATUS_NOT_FOUND, $ctx ); } if ( $e instanceof ProviderException ) { $envelope = $this->providerExceptionMapper->map( $e, $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $envelope->status() ); } if ( $e instanceof InvalidArgumentException ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Internal server error.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } /** * Resolve the current user id via the injected * `$userIdProvider`, defensively coercing non-int returns to * null so downstream code can rely on the `?int` shape. */ private function currentUserId(): ?int { $resolver = $this->userIdProvider; $userId = $resolver(); if ( $userId === null ) { return null; } if ( ! is_int( $userId ) ) { return null; } return $userId; } /** * Report whether the supplied {@see RequestContext} satisfies * the given OR-list of required capabilities, delegating to * {@see CapabilityPolicy::isAuthorized()}. * * @param RequestContext $ctx Per-request meta. * @param list $required OR-list of capability tokens. */ private function isAuthorized( RequestContext $ctx, array $required ): bool { return $this->capabilityPolicy::isAuthorized( $ctx, $required ); } }