int)`. The * {@see \Affilun\Presentation\Rest\LogsServiceProvider} * 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 the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * pattern): * * 1. Both endpoints share the §3.2 `'logs.read'` route token, * resolved by {@see CapabilityPolicy::requiredForRead()} to * the OR-list `affilun_view_logs` / `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.2 / §12.3 success * paths run otherwise. * * Exception mapping order in {@see self::mapException()}: * * 1. {@see \InvalidArgumentException} → 422 `affilun_validation_failed` * carrying the exception message in `details.exception`. The * §12.2 query-param parsing path raises this (via * {@see LogListQuery}) when a column-level filter is off- * whitelist or out of range; the §12.3 path raises this when * `recent_limit` is out of the [1, 50] range. * 2. 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 * "no internal detail leakage" posture is preserved. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Logs\UseCase\GetLogsSummaryUseCase; use Affilun\Application\Logs\UseCase\ListLogsUseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Infrastructure\Persistence\LogListQuery; 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 §12.2 / §12.3 logs endpoints. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, and the exception mapping order. */ final class LogsController { /** * Stable route token for §12.2 `GET /affilun/v1/logs`. Mapped by * {@see CapabilityPolicy} to the §3.2 `logs.read` read-side * OR-list (`affilun_view_logs` / `manage_options`). */ public const ROUTE_TOKEN_LIST = 'logs.read'; /** * Stable route token for §12.3 `GET /affilun/v1/logs/summary`. * Shares the `'logs.read'` token with the list endpoint by * design — both surfaces are admin-tier diagnostics views and * the §3.2 read-side mapping does not split them. */ public const ROUTE_TOKEN_SUMMARY = 'logs.read'; /** * Synthetic envelope key carrying the desired HTTP status. */ public const META_HTTP_STATUS = '_http_status'; /** * §12.2 list use case. */ private ListLogsUseCase $listUseCase; /** * §12.3 summary use case. */ private GetLogsSummaryUseCase $summaryUseCase; /** * §15.2 provider exception → error envelope mapper. Held even * though §12.2 / §12.3 do not raise provider exceptions * themselves, so the constructor matches the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * shape. */ 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 ListLogsUseCase $listUseCase §12.2 use case. * @param GetLogsSummaryUseCase $summaryUseCase §12.3 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( ListLogsUseCase $listUseCase, GetLogsSummaryUseCase $summaryUseCase, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, ?callable $capabilityProvider = null ) { $this->listUseCase = $listUseCase; $this->summaryUseCase = $summaryUseCase; $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 ); }; } /** * §12.2 `GET /affilun/v1/logs` dispatch. * * Reads the column-level filters (`level`, `channel`, * `related_product_id`, `related_job_id`) plus the pagination * axes (`page`, `per_page`), validates them, builds a * {@see LogListQuery}, and drives {@see ListLogsUseCase}. * Renders the §12.2 success envelope with HTTP 200. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §12.2 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 ); } $level = $this->requestAdapter->paramString( $request, 'level' ); $channel = $this->requestAdapter->paramString( $request, 'channel' ); $relatedProductId = $this->requestAdapter->paramInt( $request, 'related_product_id' ); $relatedJobId = $this->requestAdapter->paramInt( $request, 'related_job_id' ); $page = $this->requestAdapter->paramInt( $request, 'page', 1 ); $perPage = $this->requestAdapter->paramInt( $request, 'per_page', LogListQuery::DEFAULT_PER_PAGE ); $result = ValidationResult::valid(); if ( $level !== null && ! in_array( $level, LogListQuery::VALID_LEVELS, true ) ) { $result = $result->withField( 'level', sprintf( 'Must be one of: %s.', implode( ', ', LogListQuery::VALID_LEVELS ) ) ); } $result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, LogListQuery::MAX_PER_PAGE ); if ( $relatedProductId !== null && $relatedProductId < 1 ) { $result = $result->withField( 'related_product_id', 'Must be >= 1.' ); } if ( $relatedJobId !== null && $relatedJobId < 1 ) { $result = $result->withField( 'related_job_id', 'Must be >= 1.' ); } if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $query = new LogListQuery( $level, $channel, $relatedProductId, $relatedJobId, (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 $this->successResponse( $envelope, 200 ); } /** * §12.3 `GET /affilun/v1/logs/summary` dispatch. * * Reads the optional `recent_limit` query parameter (default * 10) and drives {@see GetLogsSummaryUseCase}. Renders the * §12.3 success envelope with HTTP 200. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §12.3 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function summary( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_SUMMARY ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $recentLimit = $this->requestAdapter->paramInt( $request, 'recent_limit', GetLogsSummaryUseCase::DEFAULT_RECENT_LIMIT ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireInt( $result, 'recent_limit', $recentLimit, 1, GetLogsSummaryUseCase::MAX_RECENT_LIMIT ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $summaryResult = $this->summaryUseCase->execute( (int) $recentLimit ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $summaryResult->toArray(), $ctx ); return $this->successResponse( $envelope, 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 ); } /** * Wrap a {@see SuccessEnvelope} into the response array shape * the route registration layer consumes. * * @param SuccessEnvelope $envelope Source envelope. * @param int $status §14.1 HTTP status to stamp. * * @return array */ private function successResponse( SuccessEnvelope $envelope, int $status ): array { return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * 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 ProviderException} → routed through * {@see ProviderExceptionMapper::map()} for symmetry with * the canonical * {@see \Affilun\Presentation\Rest\Controller\EditorController} * shape. * 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; §12.2 / §12.3 themselves are 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 ProviderException ) { $envelope = $this->exceptionMapper->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 ); } /** * 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 ); } }