int)`. The * {@see \Affilun\Presentation\Rest\DiagnosticsServiceProvider} * 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 LogsController}): * * 1. The endpoint names the §3.2 `'diagnostics.read'` route * token, resolved by {@see CapabilityPolicy::requiredForRead()} * to the OR-list `affilun_view_diagnostics` / `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 §12.1 success path runs * otherwise. * * Exception mapping: * * - Any {@see \Throwable} raised by the use case is translated to * 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\Diagnostics\UseCase\GetDiagnosticsUseCase; 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 Throwable; /** * REST controller for the §12.1 diagnostics endpoint. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, and the exception mapping policy. */ final class DiagnosticsController { /** * Stable route token for §12.1 `GET /affilun/v1/diagnostics`. * Mapped by {@see CapabilityPolicy} to the §3.2 * `diagnostics.read` read-side OR-list * (`affilun_view_diagnostics` / `manage_options`). */ public const ROUTE_TOKEN_READ = 'diagnostics.read'; /** * Synthetic envelope key carrying the desired HTTP status. */ public const META_HTTP_STATUS = '_http_status'; /** * §12.1 use case. */ private GetDiagnosticsUseCase $useCase; /** * §15.2 provider exception → error envelope mapper. Held even * though §12.1 does not raise provider exceptions itself, 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; /** * §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 GetDiagnosticsUseCase $useCase §12.1 use case. * @param ProviderExceptionMapper $exceptionMapper §15.2 mapper. * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param callable|null $capabilityProvider `(string): bool` resolver, or null for WP default. */ public function __construct( GetDiagnosticsUseCase $useCase, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RestRequestAdapter $requestAdapter, ?callable $capabilityProvider = null ) { $this->useCase = $useCase; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $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 ); }; } /** * §12.1 `GET /affilun/v1/diagnostics` dispatch. * * Authorizes the caller against the §3.2 `diagnostics.read` * route token and drives {@see GetDiagnosticsUseCase}. Renders * the §12.1 success envelope with HTTP 200. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §12.1 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function read( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_READ ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } try { $result = $this->useCase->execute(); } catch ( Throwable $e ) { unset( $e ); return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Internal server error.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } $envelope = SuccessEnvelope::fromContext( $result->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 ); } /** * 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 ); } }