` the * controller wants to surface under the §4.1 `data` key. For * §4.2 list responses the {@see self::list()} factory wraps the * items / pagination tuple into the canonical * `array('items' => ..., 'pagination' => ...)` shape so the * controller does not have to repeat that structure. * * - `requestId` is the §4.1 `meta.request_id` token; non-empty * enforced at construction time so a programming error in a * controller surfaces here rather than producing an envelope * whose meta block is silently missing the correlation hook * that operations dashboards depend on. * * - `correlationId` is the §4.1 `meta.correlation_id` token, or * null when the controller wants the field omitted from meta * entirely. The {@see self::toArray()} serializer drops the key * when the value is null so the on-the-wire JSON does not carry * a `"correlation_id": null` placeholder. * * - `timestamp` is the §2.4 RFC 3339 / ISO 8601 timestamp of the * response. Always rendered in UTC by {@see self::fromContext()} * ("`Y-m-d\\TH:i:s\\Z`") to match the §2.4 "原則: UTC で返す" * posture; callers that need a timezone-suffixed form may pass * it explicitly to the constructor. * * - `extraMeta` is an `array` of additional meta * keys that get merged into the meta block. Used by §4.2 list * responses to attach pagination metadata at the meta level * when a future slice wants that, and as the open-ended * extension point §18.2 ("フィールド追加は許可") relies on. * * 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.1 / §4.2 success envelope. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §4.1 / §4.2 and the per-field validation * rules. */ final class SuccessEnvelope { /** * Payload to surface under the §4.1 `data` key. * * @var array */ private array $data; /** * §4.1 `meta.request_id` token. */ private string $requestId; /** * §4.1 `meta.correlation_id` token, or null to omit from meta. */ private ?string $correlationId; /** * §2.4 RFC 3339 / ISO 8601 timestamp. */ private string $timestamp; /** * Additional meta keys merged into the meta block. * * @var array */ private array $extraMeta; /** * Construct a success envelope with explicit values for every * §4.1 field. * * @param array $data §4.1 payload. * @param string $requestId §4.1 request id. * @param string|null $correlationId §4.1 correlation id, or null to omit. * @param string $timestamp §2.4 RFC 3339 timestamp. * @param array $extraMeta Additional meta keys to merge. * * @throws InvalidArgumentException When `requestId`, `timestamp`, * or non-null `correlationId` * is empty after trimming. */ public function __construct( array $data, string $requestId, ?string $correlationId, string $timestamp, array $extraMeta = array() ) { if ( trim( $requestId ) === '' ) { $message = 'SuccessEnvelope requestId must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $correlationId !== null && trim( $correlationId ) === '' ) { $message = 'SuccessEnvelope 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( $message ); } if ( trim( $timestamp ) === '' ) { $message = 'SuccessEnvelope timestamp must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } foreach ( $extraMeta as $key => $_value ) { if ( ! is_string( $key ) || trim( $key ) === '' ) { $message = 'SuccessEnvelope extraMeta keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } $this->data = $data; $this->requestId = $requestId; $this->correlationId = $correlationId; $this->timestamp = $timestamp; $this->extraMeta = $extraMeta; } /** * §4.1 payload to surface under the `data` key. * * @return array */ public function data(): array { return $this->data; } /** * §4.1 `meta.request_id` token. */ public function requestId(): string { return $this->requestId; } /** * §4.1 `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; } /** * Additional meta keys merged into the meta block. * * @return array */ public function extraMeta(): array { return $this->extraMeta; } /** * Serialize the envelope into the §4.1 / §4.2 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. * * `extraMeta` keys are merged into the meta block AFTER the * canonical `request_id` / `correlation_id` / `timestamp` * entries; a colliding extra-meta key wins so a future slice * can override the timestamp shape if needed. * * @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; foreach ( $this->extraMeta as $key => $value ) { $meta[ $key ] = $value; } return array( 'success' => true, 'data' => $this->data, '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 array $data §4.1 payload. * @param RequestContext $ctx Per-request meta source. * @param array $extraMeta Additional meta keys to merge. */ public static function fromContext( array $data, RequestContext $ctx, array $extraMeta = array() ): self { return new self( $data, $ctx->requestId(), $ctx->correlationId(), gmdate( 'Y-m-d\TH:i:s\Z' ), $extraMeta ); } /** * Convenience factory for §4.2 list responses. * * Wraps the supplied items / pagination tuple into the canonical * `array('items' => $items, 'pagination' => $pagination)` shape * documented by §4.2 so the controller does not have to repeat * the structure at every call site. * * @param list> $items List of item payloads. * @param array $pagination §4.2 pagination block. * @param RequestContext $ctx Per-request meta source. */ public static function list( array $items, array $pagination, RequestContext $ctx ): self { return self::fromContext( array( 'items' => $items, 'pagination' => $pagination, ), $ctx ); } }