= 1` at the * mapper boundary surfaces a programming error here rather * than at the REST boundary, where a `0` id would silently * misrepresent persistence state. * * - The §6.3 `price.observed_at` field is sourced from * {@see \Affilun\Domain\Pricing\PriceInfo::observedAt()} * directly because the snapshot itself carries the * observation timestamp the provider returned — there is no * `lastSyncedAt`-style proxy on the search path. * * - The §6.3 `provider_meta` field is the open-ended extension * point §6.3 reserves for provider-specific metadata * (`review_average`, `review_count`, ...). Phase 6 returns it * as the empty array `array()`; Phase 9+ slices wire the * per-provider extraction. The field is present-but-empty so * clients see the §6.3 key set verbatim and can render a * "no provider meta" state without a key-existence branch. * * - The §6.3 `availability_status` field surfaces the snapshot's * {@see \Affilun\Domain\Pricing\AvailabilityInfo} * status token verbatim. The snapshot's * {@see AvailabilityInfo::__construct()} validates against the * §7.6 closed token set, so the mapper does not re-validate. * * - All output keys are snake_case because §6.3 / §8.1 specify * the wire shape literally with snake_case keys. * * @package Affilun\Presentation\Rest\Mapper */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Mapper; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use DateTimeImmutable; use InvalidArgumentException; /** * Stateless serializer that produces the §6.3 SearchResultItem * array shape from a {@see ProductSnapshot}. * * See the file-level docblock for the rationale, the * `$savedId` validation contract, and the Phase-6 * `provider_meta = array()` default. */ final class SearchResultItemMapper { /** * Convert a {@see ProductSnapshot} into the §6.3 array shape. * * The returned array's top-level key set and ordering match * §6.3 / §8.1 verbatim; deviating from that order is a * breaking change for REST clients. * * @param ProductSnapshot $snapshot Search-result snapshot. * @param int|null $savedId Persisted product id, or null * when the snapshot has not * been saved. * * @return array * * @throws InvalidArgumentException When `$savedId` is non-null * but not a positive integer * (`>= 1`). */ public function map( ProductSnapshot $snapshot, ?int $savedId = null ): array { if ( $savedId !== null && $savedId < 1 ) { $message = sprintf( 'SearchResultItemMapper savedId must be a positive integer when non-null, got %d.', $savedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return array( 'id' => $savedId, 'provider' => $snapshot->provider(), 'provider_item_id' => $snapshot->providerProductId()->value(), 'title' => $snapshot->title(), 'brand' => $snapshot->brand(), 'image_primary_url' => $snapshot->primaryImage(), 'price' => self::buildPriceFromSnapshot( $snapshot->price() ), 'availability_status' => $snapshot->availability()->status(), 'links' => array( 'detail_url' => $snapshot->detailUrl(), ), 'provider_meta' => array(), 'is_saved' => $savedId !== null, ); } /** * Build the §6.3 `price` sub-object from a {@see PriceInfo}, or * null when the snapshot carries no price. * * @param PriceInfo|null $price Source price snapshot, or null. * * @return array|null */ private static function buildPriceFromSnapshot( ?PriceInfo $price ): ?array { if ( $price === null ) { return null; } return array( 'amount' => $price->amount(), 'currency' => $price->currency() ?? 'UNKNOWN', 'label' => $price->label(), 'observed_at' => self::formatRfc3339( $price->observedAt() ), ); } /** * Format a {@see DateTimeImmutable} as an RFC 3339 / ISO 8601 * UTC string per §2.4 ("原則: UTC で返す"), or null when * `$dt` is null. * * @param DateTimeImmutable|null $dt Timestamp to format, or null. */ private static function formatRfc3339( ?DateTimeImmutable $dt ): ?string { if ( $dt === null ) { return null; } return gmdate( 'Y-m-d\TH:i:s\Z', $dt->getTimestamp() ); } }