` of structured * diagnostic context. For §4.4 field-validation responses the * {@see self::validation()} factory wraps a fields map into * `array('fields' => array('keywords' => array('Required.'), ...))`. * * - `retryable` is the §10.2 retryability hint a future client * surface uses to decide whether to expose a "try again" UI * affordance. * * - `requestId` / `correlationId` / `timestamp` mirror the * {@see SuccessEnvelope} meta block; the same * "omit correlation_id when null" rule applies. * * Validation failures raise {@see InvalidArgumentException}; the * messages are developer-facing and never rendered to HTML. * * @package Affilun\Presentation\Rest\Response */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Response; use Affilun\Presentation\Rest\RequestContext; use InvalidArgumentException; /** * Immutable §4.3 / §4.4 error envelope. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §4.3 / §4.4 / §14.1 / §15.1 / §15.2 and the * per-field validation rules. */ final class ErrorEnvelope { /** * §15.1 grammar for the `code` field. * * Held as a private constant so the constructor uses the same * regex literal as future debugging surfaces. */ private const CODE_GRAMMAR = '/^affilun_[a-z][a-z0-9_]*$/'; /** * §14.1 HTTP status: parameter format invalid. */ public const STATUS_BAD_REQUEST = 400; /** * §14.1 HTTP status: caller is not authenticated. */ public const STATUS_UNAUTHORIZED = 401; /** * §14.1 HTTP status: caller lacks the required capability. */ public const STATUS_FORBIDDEN = 403; /** * §14.1 HTTP status: target resource does not exist. */ public const STATUS_NOT_FOUND = 404; /** * §14.1 HTTP status: state conflict (e.g. delete-in-use). */ public const STATUS_CONFLICT = 409; /** * §14.1 HTTP status: validation failure. */ public const STATUS_UNPROCESSABLE_ENTITY = 422; /** * §14.1 HTTP status: rate-limit refusal. */ public const STATUS_TOO_MANY_REQUESTS = 429; /** * §14.1 HTTP status: unexpected internal failure. */ public const STATUS_INTERNAL_SERVER_ERROR = 500; /** * §14.1 HTTP status: transient upstream / provider unavailability. */ public const STATUS_SERVICE_UNAVAILABLE = 503; /** * §15.2 representative code: validation failed. */ public const CODE_VALIDATION_FAILED = 'affilun_validation_failed'; /** * §15.2 representative code: unsupported provider supplied. */ public const CODE_INVALID_PROVIDER = 'affilun_invalid_provider'; /** * §15.2 representative code: product lookup miss. */ public const CODE_PRODUCT_NOT_FOUND = 'affilun_product_not_found'; /** * §15.2 representative code: caller lacks the required capability. */ public const CODE_FORBIDDEN = 'affilun_forbidden'; /** * §15.2 representative code: caller is not authenticated. */ public const CODE_UNAUTHORIZED = 'affilun_unauthorized'; /** * §15.2 representative code: rate-limit refusal surfaced to the API. */ public const CODE_RATE_LIMITED = 'affilun_rate_limited'; /** * §15.2 representative code: provider transient unavailability. */ public const CODE_PROVIDER_UNAVAILABLE = 'affilun_provider_unavailable'; /** * §15.2 representative code: catch-all internal failure. */ public const CODE_INTERNAL_ERROR = 'affilun_internal_error'; /** * §15.2 representative code: sync-job lookup miss. */ public const CODE_SYNC_JOB_NOT_FOUND = 'affilun_sync_job_not_found'; /** * §15.1 stable error code. */ private string $code; /** * Human-readable summary of the failure. */ private string $message; /** * §14.1 HTTP status code. */ private int $status; /** * Structured diagnostic context. * * @var array */ private array $details; /** * §10.2 retryability hint. */ private bool $retryable; /** * §4.3 `meta.request_id` token. */ private string $requestId; /** * §4.3 `meta.correlation_id` token, or null to omit from meta. */ private ?string $correlationId; /** * §2.4 RFC 3339 / ISO 8601 timestamp. */ private string $timestamp; /** * Construct an error envelope with explicit values for every * §4.3 field. * * @param string $code §15.1 stable error code. * @param string $message Human-readable summary. * @param int $status §14.1 HTTP status code. * @param array $details Structured diagnostic context. * @param bool $retryable §10.2 retryability hint. * @param string $requestId §4.3 request id. * @param string|null $correlationId §4.3 correlation id, or null to omit. * @param string $timestamp §2.4 RFC 3339 timestamp. * * @throws InvalidArgumentException When any field violates the * per-field validation rules. */ public function __construct( string $code, string $message, int $status, array $details, bool $retryable, string $requestId, ?string $correlationId, string $timestamp ) { if ( preg_match( self::CODE_GRAMMAR, $code ) !== 1 ) { $invalid = sprintf( 'ErrorEnvelope code must match %s; got "%s".', self::CODE_GRAMMAR, $code ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } if ( trim( $message ) === '' ) { $invalid = 'ErrorEnvelope message must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } if ( ! in_array( $status, self::validStatusCodes(), true ) ) { $invalid = sprintf( 'ErrorEnvelope status must be one of [%s]; got %d.', implode( ', ', array_map( 'strval', self::validStatusCodes() ) ), $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } foreach ( $details as $key => $_value ) { if ( ! is_string( $key ) || trim( $key ) === '' ) { $invalid = 'ErrorEnvelope details keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } } if ( trim( $requestId ) === '' ) { $invalid = 'ErrorEnvelope requestId must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } if ( $correlationId !== null && trim( $correlationId ) === '' ) { $invalid = 'ErrorEnvelope correlationId must be a non-empty string when non-null.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } if ( trim( $timestamp ) === '' ) { $invalid = 'ErrorEnvelope timestamp must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $this->code = $code; $this->message = $message; $this->status = $status; $this->details = $details; $this->retryable = $retryable; $this->requestId = $requestId; $this->correlationId = $correlationId; $this->timestamp = $timestamp; } /** * §15.1 stable error code carried by this envelope. */ public function code(): string { return $this->code; } /** * Human-readable summary of the failure. */ public function message(): string { return $this->message; } /** * §14.1 HTTP status code. */ public function status(): int { return $this->status; } /** * Structured diagnostic context. * * @return array */ public function details(): array { return $this->details; } /** * §10.2 retryability hint. */ public function retryable(): bool { return $this->retryable; } /** * §4.3 `meta.request_id` token. */ public function requestId(): string { return $this->requestId; } /** * §4.3 `meta.correlation_id` token, or null when the field * should be omitted from the meta block. */ public function correlationId(): ?string { return $this->correlationId; } /** * §2.4 RFC 3339 / ISO 8601 timestamp. */ public function timestamp(): string { return $this->timestamp; } /** * Serialize the envelope into the §4.3 wire shape. * * The `meta.correlation_id` key is omitted entirely when the * correlation id is null so the on-the-wire JSON does not carry * a `"correlation_id": null` placeholder. * * @return array */ public function toArray(): array { $meta = array( 'request_id' => $this->requestId, ); if ( $this->correlationId !== null ) { $meta['correlation_id'] = $this->correlationId; } $meta['timestamp'] = $this->timestamp; return array( 'success' => false, 'error' => array( 'code' => $this->code, 'message' => $this->message, 'status' => $this->status, 'details' => $this->details, 'retryable' => $this->retryable, ), 'meta' => $meta, ); } /** * Convenience factory that pulls `requestId` / `correlationId` * from a {@see RequestContext} and stamps the current UTC * timestamp. * * The timestamp is rendered as `Y-m-d\\TH:i:s\\Z` (UTC) per * §2.4 "原則: UTC で返す". * * @param string $code §15.1 stable error code. * @param string $message Human-readable summary. * @param int $status §14.1 HTTP status code. * @param RequestContext $ctx Per-request meta source. * @param array $details Structured diagnostic context. * @param bool $retryable §10.2 retryability hint. */ public static function fromContext( string $code, string $message, int $status, RequestContext $ctx, array $details = array(), bool $retryable = false ): self { return new self( $code, $message, $status, $details, $retryable, $ctx->requestId(), $ctx->correlationId(), gmdate( 'Y-m-d\TH:i:s\Z' ) ); } /** * Convenience factory for §4.4 field-validation responses. * * Wraps the supplied per-field error map into the canonical * `array('fields' => $fields)` shape documented by §4.4 and uses * {@see self::CODE_VALIDATION_FAILED} / status 422. * * @param array> $fields Per-field error map. * @param RequestContext $ctx Per-request meta source. * @param string $message Human-readable summary. */ public static function validation( array $fields, RequestContext $ctx, string $message = 'Validation failed.' ): self { return self::fromContext( self::CODE_VALIDATION_FAILED, $message, self::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'fields' => $fields ), false ); } /** * Return the §14.1 closed set of HTTP status codes the envelope * accepts. * * @return list */ public static function validStatusCodes(): array { return array( self::STATUS_BAD_REQUEST, self::STATUS_UNAUTHORIZED, self::STATUS_FORBIDDEN, self::STATUS_NOT_FOUND, self::STATUS_CONFLICT, self::STATUS_UNPROCESSABLE_ENTITY, self::STATUS_TOO_MANY_REQUESTS, self::STATUS_INTERNAL_SERVER_ERROR, self::STATUS_SERVICE_UNAVAILABLE, ); } }