int)`. The * {@see \Affilun\Presentation\Rest\SyncJobsServiceProvider} * 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. * * Wire ⇔ DB status mapping placement: * * The §11.3 surface accepts a wire-vocabulary `status` query * parameter (`queued|running|completed|failed|cancelled`) but the * persistence layer's * {@see \Affilun\Infrastructure\Persistence\SyncJobListQuery} * speaks DB-vocabulary * (`pending|running|completed|failed|cancelled`). Per §6.0 the only * spelling difference is wire `queued` ⇔ DB `pending`. This * controller is the inbound seam where wire `queued` is rewritten * to DB `pending` BEFORE constructing the query VO; the use case * then performs the inverse DB → wire substitution on each * projected row. Every other wire status passes through unchanged * so the persistence layer can validate the closed set without * re-reasoning about wire spelling. * * Permission resolution flow (mirrors {@see LogsController}): * * 1. Both endpoints share the §3.2 `'sync.jobs.read'` route token, * resolved by {@see CapabilityPolicy::requiredForRead()} to the * OR-list `affilun_run_sync` / `manage_options`. * 2. 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()}. * 3. A failed authorization check short-circuits to a 403 * `affilun_forbidden` error envelope; the §11.3 / §11.4 success * paths run otherwise. * * Exception mapping order in {@see self::mapException()}: * * 1. {@see SyncJobNotFoundException} → 404 * `affilun_sync_job_not_found`. * 2. {@see \InvalidArgumentException} → 422 `affilun_validation_failed` * carrying the exception message in `details.exception`. * 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. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\SyncJobs\Exception\SyncJobNotFoundException; use Affilun\Application\SyncJobs\UseCase\GetSyncJobUseCase; use Affilun\Application\SyncJobs\UseCase\ListSyncJobsUseCase; use Affilun\Infrastructure\Persistence\SyncJobListQuery; 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.3 / §11.4 sync-jobs endpoints. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the wire ⇔ DB status * mapping placement, the permission resolution flow, and the * exception mapping order. */ final class SyncJobsController { /** * Stable route token for §11.3 `GET /affilun/v1/sync/jobs`. Mapped * by {@see CapabilityPolicy} to the §3.2 `sync.jobs.read` * read-side OR-list (`affilun_run_sync` / `manage_options`). */ public const ROUTE_TOKEN_LIST = 'sync.jobs.read'; /** * Stable route token for §11.4 `GET /affilun/v1/sync/jobs/{id}`. * Shares the `'sync.jobs.read'` token with the list endpoint by * design — both surfaces are admin-tier sync observability views * and the §3.2 read-side mapping does not split them. */ public const ROUTE_TOKEN_DETAIL = 'sync.jobs.read'; /** * Synthetic envelope key carrying the desired HTTP status. */ public const META_HTTP_STATUS = '_http_status'; /** * Wire-vocabulary status corresponding to DB `pending` per §6.0. */ private const WIRE_STATUS_QUEUED = 'queued'; /** * DB-vocabulary status corresponding to wire `queued` per §6.0. */ private const DB_STATUS_PENDING = 'pending'; /** * §11.3 list use case. */ private ListSyncJobsUseCase $listUseCase; /** * §11.4 detail use case. */ private GetSyncJobUseCase $getUseCase; /** * §15.2 provider exception → error envelope mapper. Held even * though §11.3 / §11.4 do not raise provider exceptions * themselves, so the constructor matches the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * shape. * * @phpstan-ignore property.onlyWritten */ private ProviderExceptionMapper $exceptionMapper; /** * §3.2 / §3.3 capability policy. */ private CapabilityPolicy $capabilityPolicy; /** * §17.1 request input validator. */ private RequestValidator $requestValidator; /** * §3 request adapter (header / param / body extraction). */ private RestRequestAdapter $requestAdapter; /** * 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. * * @param ListSyncJobsUseCase $listUseCase §11.3 use case. * @param GetSyncJobUseCase $getUseCase §11.4 use case. * @param ProviderExceptionMapper $exceptionMapper §15.2 mapper. * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RequestValidator $requestValidator §17.1 validator. * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param callable|null $capabilityProvider `(string): bool` resolver, or null for WP default. */ public function __construct( ListSyncJobsUseCase $listUseCase, GetSyncJobUseCase $getUseCase, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, ?callable $capabilityProvider = null ) { $this->listUseCase = $listUseCase; $this->getUseCase = $getUseCase; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->requestValidator = $requestValidator; $this->requestAdapter = $requestAdapter; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §11.3 `GET /affilun/v1/sync/jobs` dispatch. * * Reads the wire-vocabulary `status` filter, the `job_type` * filter, and the pagination axes (`page`, `per_page`), * substitutes wire `queued` → DB `pending` per §6.0, builds a * {@see SyncJobListQuery}, and drives * {@see ListSyncJobsUseCase}. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §11.3 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function list( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_LIST ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $status = $this->requestAdapter->paramString( $request, 'status' ); $jobType = $this->requestAdapter->paramString( $request, 'job_type' ); $page = $this->requestAdapter->paramInt( $request, 'page', 1 ); $perPage = $this->requestAdapter->paramInt( $request, 'per_page', SyncJobListQuery::DEFAULT_PER_PAGE ); // §6.0 wire → DB status substitution: wire `queued` becomes DB // `pending` before reaching the persistence-layer query VO. // Every other wire status passes through unchanged so the // query VO can reject off-vocabulary values loudly. if ( $status === self::WIRE_STATUS_QUEUED ) { $status = self::DB_STATUS_PENDING; } $result = ValidationResult::valid(); $result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, SyncJobListQuery::MAX_PER_PAGE ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $query = new SyncJobListQuery( $status, $jobType, (int) $page, (int) $perPage ); } catch ( InvalidArgumentException $e ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } try { $listResult = $this->listUseCase->execute( $query ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $listResult->toArray(), $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => 200 ); } /** * §11.4 `GET /affilun/v1/sync/jobs/{id}` dispatch. * * Validates the `id` route parameter as a positive integer, * drives {@see GetSyncJobUseCase}, and renders the §11.4 * success envelope with HTTP 200. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §11.4 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function detail( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_DETAIL ); $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->requestValidator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $getResult = $this->getUseCase->execute( (int) $id ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $getResult->toArray(), $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => 200 ); } /** * Build the per-request {@see RequestContext} carrying 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 { $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, null, $caps ); } /** * 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 SyncJobNotFoundException} → 404 * `affilun_sync_job_not_found`. * 2. {@see InvalidArgumentException} → 422 with * `details.exception = $e->getMessage()`. * 3. 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.3 / §11.4 themselves do not raise provider * exceptions. * * @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 SyncJobNotFoundException ) { return $this->errorResponse( ErrorEnvelope::CODE_SYNC_JOB_NOT_FOUND, $e->getMessage() !== '' ? $e->getMessage() : 'Sync job not found.', ErrorEnvelope::STATUS_NOT_FOUND, $ctx, array( 'id' => $e->id() ) ); } 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 ); } /** * 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 ); } }