Computing > Internet"), normalized to a flat list * of leaf-most-first strings. Empty by default. Each entry is * validated as a non-empty trimmed string; the list is * rejected when any entry is not a string or is empty after * trimming. * * - `attributes` is the provider's structured attribute map * (color, size, material, ...). Held as an associative array * keyed by canonical attribute name. Validated to ensure every * key is a non-empty string and every value is a scalar or * null; nested arrays are rejected because §7.4 expects a flat * attribute surface for the rendering layer. * * - `fetchedAt` is the UTC timestamp at which the provider * returned this snapshot. Required. Used by the sync layer to * detect stale snapshots and by the render layer to decide * whether to display a "as of {date}" caption. * * - `rawMeta` is the provider-native metadata blob preserved for * debugging and provider-specific extensions. Held as an * associative array; per §7.4 ("UI は直接依存しない") consuming * surfaces MUST NOT branch on its contents. Validated only to * reject non-string keys. * * Instances are immutable. Two mutation helpers are provided because * they map to distinct, frequent application-layer flows: * * - {@see self::withAffiliateUrl()} populates the `affiliateUrl` * field after the affiliate-link builder has produced one. * This matches the §7.4 "build 時点で未設定でもよい" allowance. * * - {@see self::withFetchedAt()} produces a re-stamped copy when * the same payload is observed again from a cache layer; useful * for tests and for the sync layer's "touch" path that wants to * extend a snapshot's freshness window without re-fetching. * * Other fields have no in-place edit path because a meaningful change * (price, availability, title) should be modeled as a fresh * {@see ProductSnapshot} carrying its own `fetchedAt`, not as an * in-place patch. * * @package Affilun\Domain\Product */ declare(strict_types=1); namespace Affilun\Domain\Product; use Affilun\Domain\Pricing\AvailabilityInfo; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Provider\ProviderProductId; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; /** * Immutable normalized snapshot of a single external product. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §7.4, the cross-provider field * conventions, and the rationale for the two `with*()` mutation * helpers exposed here. */ final class ProductSnapshot { /** * Stable provider key per §4. Held redundantly alongside * {@see self::$providerProductId} so consumers that only need * the key (REST list responses, log lines) do not have to * unwrap the identifier object. */ private string $provider; /** * Canonical `(provider, value)` identifier for the external * product per §7.3. */ private ProviderProductId $providerProductId; /** * Human-readable product title per §7.4. Required. */ private string $title; /** * Brand / manufacturer / shop name, or null when the provider * did not surface one. */ private ?string $brand; /** * Long-form product description, or null. */ private ?string $description; /** * Canonical product detail URL on the provider's own site. * Required and validated as `http(s)://...`. */ private string $detailUrl; /** * Affiliate-tracked URL, or null when the affiliate link has * not been minted yet (per §7.4 build-time allowance). */ private ?string $affiliateUrl; /** * Primary product image URL, or null. */ private ?string $primaryImage; /** * Additional product image URLs. * * @var list */ private array $galleryImages; /** * Price snapshot, or null when the provider response elided * price data. */ private ?PriceInfo $price; /** * Availability snapshot. Required; absent signals are encoded * as {@see AvailabilityInfo::unknown()} rather than null. */ private AvailabilityInfo $availability; /** * Average review score as a non-negative decimal string, or * null when the provider did not report one. */ private ?string $rating; /** * Number of reviews the provider reported, or null. */ private ?int $reviewCount; /** * Category-path entries in leaf-most-first order. * * @var list */ private array $categories; /** * Structured attribute map keyed by canonical attribute name. * * @var array */ private array $attributes; /** * UTC timestamp at which the provider returned this snapshot. */ private DateTimeImmutable $fetchedAt; /** * Provider-native metadata blob preserved for debugging. * * @var array */ private array $rawMeta; /** * Construct a snapshot with explicit values for every field. * * The constructor is exhaustive so a programming error (missing * required field on a write, swapped detail / affiliate URL) * surfaces at the type system boundary rather than at the * rendering layer. Named-argument construction is the ergonomic * entry point for most call sites. * * Validation summary: * * - `provider` is trimmed and matched against * `providerProductId->provider()`; mismatched keys raise * {@see InvalidArgumentException} per §7.3. * - `title` and `detailUrl` are required and rejected when * empty after trimming. `detailUrl` is additionally * validated as a `http(s)://...` URL. * - `affiliateUrl` and `primaryImage`, when non-null, are * trimmed and validated as `http(s)://...` URLs. * - `galleryImages` and `categories` reject any non-string * entry and any entry that is empty after trimming; * `galleryImages` entries are additionally URL-validated. * - `rating`, when non-null, is validated as a non-negative * decimal string in the same grammar `PriceInfo::amount` * accepts. * - `reviewCount`, when non-null, is rejected for negative * values. * - `attributes` rejects non-string keys and rejects values * that are not scalar or null. * - `rawMeta` rejects non-string keys. * - `fetchedAt` is normalized to UTC on storage. * * @param string $provider Stable provider key. * @param ProviderProductId $providerProductId Canonical id. * @param string $title Product title. * @param string|null $brand Brand / shop, or null. * @param string|null $description Long description, or null. * @param string $detailUrl Provider detail URL. * @param string|null $affiliateUrl Affiliate URL, or null. * @param string|null $primaryImage Primary image URL, or null. * @param list $galleryImages Gallery URLs. * @param PriceInfo|null $price Price snapshot, or null. * @param AvailabilityInfo $availability Availability snapshot. * @param string|null $rating Average review score. * @param int|null $reviewCount Number of reviews. * @param list $categories Category path entries. * @param array $attributes Structured attributes. * @param DateTimeImmutable $fetchedAt Observation timestamp. * @param array $rawMeta Raw provider metadata. * * @throws InvalidArgumentException When any field violates the * §7.4 contract or the * cross-provider validation * rules summarized above. */ public function __construct( string $provider, ProviderProductId $providerProductId, string $title, ?string $brand, ?string $description, string $detailUrl, ?string $affiliateUrl, ?string $primaryImage, array $galleryImages, ?PriceInfo $price, AvailabilityInfo $availability, ?string $rating, ?int $reviewCount, array $categories, array $attributes, DateTimeImmutable $fetchedAt, array $rawMeta ) { $normalizedProvider = trim( $provider ); if ( $normalizedProvider === '' ) { $message = 'ProductSnapshot requires a non-empty provider key.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $providerProductId->belongsTo( $normalizedProvider ) ) { $message = sprintf( 'ProductSnapshot provider key "%s" does not match providerProductId provider "%s"; ' . 'see provider-api-contracts.md §7.3.', $normalizedProvider, $providerProductId->provider() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->provider = $normalizedProvider; $this->providerProductId = $providerProductId; $this->title = self::normalizeRequiredString( 'title', $title ); $this->brand = self::normalizeOptionalString( 'brand', $brand ); $this->description = self::normalizeOptionalString( 'description', $description ); $this->detailUrl = self::normalizeRequiredUrl( 'detailUrl', $detailUrl ); $this->affiliateUrl = self::normalizeOptionalUrl( 'affiliateUrl', $affiliateUrl ); $this->primaryImage = self::normalizeOptionalUrl( 'primaryImage', $primaryImage ); $this->galleryImages = self::normalizeGalleryImages( $galleryImages ); $this->price = $price; $this->availability = $availability; $this->rating = self::normalizeRating( $rating ); $this->reviewCount = self::normalizeReviewCount( $reviewCount ); $this->categories = self::normalizeCategories( $categories ); $this->attributes = self::normalizeAttributes( $attributes ); $this->fetchedAt = self::normalizeToUtc( $fetchedAt ); $this->rawMeta = self::normalizeRawMeta( $rawMeta ); } /** * Stable provider key per §4. */ public function provider(): string { return $this->provider; } /** * Canonical `(provider, value)` identifier per §7.3. */ public function providerProductId(): ProviderProductId { return $this->providerProductId; } /** * Human-readable product title. */ public function title(): string { return $this->title; } /** * Brand / shop name, or null. */ public function brand(): ?string { return $this->brand; } /** * Long-form product description, or null. */ public function description(): ?string { return $this->description; } /** * Canonical detail URL on the provider's own site. */ public function detailUrl(): string { return $this->detailUrl; } /** * Affiliate-tracked URL, or null when not yet minted. */ public function affiliateUrl(): ?string { return $this->affiliateUrl; } /** * Primary product image URL, or null. */ public function primaryImage(): ?string { return $this->primaryImage; } /** * Additional product image URLs. * * @return list */ public function galleryImages(): array { return $this->galleryImages; } /** * Price snapshot, or null. */ public function price(): ?PriceInfo { return $this->price; } /** * Availability snapshot. */ public function availability(): AvailabilityInfo { return $this->availability; } /** * Average review score as a decimal string, or null. */ public function rating(): ?string { return $this->rating; } /** * Number of reviews, or null. */ public function reviewCount(): ?int { return $this->reviewCount; } /** * Category-path entries in leaf-most-first order. * * @return list */ public function categories(): array { return $this->categories; } /** * Structured attribute map. * * @return array */ public function attributes(): array { return $this->attributes; } /** * UTC observation timestamp. */ public function fetchedAt(): DateTimeImmutable { return $this->fetchedAt; } /** * Provider-native metadata blob. * * Per §7.4 the rendering layer MUST NOT branch on this map's * contents; it is preserved purely for diagnostics and for * provider-specific extensions outside the cross-provider * contract. * * @return array */ public function rawMeta(): array { return $this->rawMeta; } /** * Produce a copy of this snapshot with the affiliate URL set. * * Matches the §7.4 "build 時点で未設定でもよい" allowance: the * affiliate-link builder constructs the URL after the snapshot * is in hand, and this helper threads the result back into the * snapshot without re-validating every other field. * * Passing null clears the affiliate URL — useful for the sync * layer's "rebuild affiliate link" path that wants to surface a * snapshot whose affiliate URL has been invalidated. * * @param string|null $affiliateUrl New affiliate URL, or null * to clear. * * @throws InvalidArgumentException When `$affiliateUrl` is a * non-null string that fails * the URL validator. */ public function withAffiliateUrl( ?string $affiliateUrl ): self { $clone = clone $this; $clone->affiliateUrl = self::normalizeOptionalUrl( 'affiliateUrl', $affiliateUrl ); return $clone; } /** * Produce a copy of this snapshot with `fetchedAt` advanced. * * Used by the sync layer's "touch" path that wants to extend a * snapshot's freshness window without re-fetching the provider. * * @param DateTimeImmutable $fetchedAt New observation timestamp; * normalized to UTC on * storage. */ public function withFetchedAt( DateTimeImmutable $fetchedAt ): self { $clone = clone $this; $clone->fetchedAt = self::normalizeToUtc( $fetchedAt ); return $clone; } /** * Validate and normalize a required string field. * * @param string $field Field name for the error message. * @param string $value Raw value. * * @throws InvalidArgumentException When `$value` is empty after trimming. */ private static function normalizeRequiredString( string $field, string $value ): string { $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProductSnapshot %s must be a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable string field. * * @param string $field Field name for the error message. * @param string|null $value Raw value. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProductSnapshot %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate a required `http(s)://` URL. * * @param string $field Field name for the error message. * @param string $value Raw URL. * * @throws InvalidArgumentException When `$value` is empty or * not a syntactically-valid * `http(s)://` URL. */ private static function normalizeRequiredUrl( string $field, string $value ): string { $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProductSnapshot %s must be a non-empty URL.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } self::assertHttpUrl( $field, $trimmed ); return $trimmed; } /** * Validate an optional `http(s)://` URL. * * @param string $field Field name for the error message. * @param string|null $value Raw URL. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming or is * not a syntactically-valid * `http(s)://` URL. */ private static function normalizeOptionalUrl( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ProductSnapshot %s must be null or a non-empty URL.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } self::assertHttpUrl( $field, $trimmed ); return $trimmed; } /** * Assert that `$value` is a syntactically-valid `http(s)://` * URL, raising {@see InvalidArgumentException} otherwise. * * Uses PHP's `filter_var()` because writing a URL grammar by * hand is famously brittle; the additional scheme guard rejects * `ftp://`, `file://`, `data:`, and `javascript:` even when * they pass the underlying validator. * * @param string $field Field name for the error message. * @param string $value Trimmed URL value. * * @throws InvalidArgumentException When `$value` is not a * valid `http(s)://` URL. */ private static function assertHttpUrl( string $field, string $value ): void { if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) { $message = sprintf( 'ProductSnapshot %s must be a valid http(s):// URL; got "%s".', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency. $scheme = strtolower( (string) parse_url( $value, PHP_URL_SCHEME ) ); if ( $scheme !== 'http' && $scheme !== 'https' ) { $message = sprintf( 'ProductSnapshot %s must use the http or https scheme; got "%s".', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate and normalize the gallery-images list. * * @param array $images Raw gallery list. * * @return list * * @throws InvalidArgumentException When any entry is not a * string, is empty after * trimming, or fails URL * validation. */ private static function normalizeGalleryImages( array $images ): array { $normalized = array(); foreach ( $images as $index => $image ) { if ( ! is_string( $image ) ) { $message = sprintf( 'ProductSnapshot galleryImages[%s] must be a string.', (string) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = self::normalizeRequiredUrl( sprintf( 'galleryImages[%s]', (string) $index ), $image ); } return $normalized; } /** * Validate and normalize the optional rating field. * * Accepts the same decimal-string grammar `PriceInfo::amount` * accepts. The upper bound is deliberately not enforced here; * see the file-level docblock for the rationale. * * @param string|null $rating Raw rating, or null. * * @throws InvalidArgumentException When `$rating` is not a * non-negative decimal string. */ private static function normalizeRating( ?string $rating ): ?string { if ( $rating === null ) { return null; } $trimmed = trim( $rating ); if ( $trimmed === '' ) { $message = 'ProductSnapshot rating must be null or a non-empty decimal string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $trimmed[0] === '+' ) { $trimmed = substr( $trimmed, 1 ); } if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) { $message = sprintf( 'ProductSnapshot rating must be a non-negative decimal string; got "%s".', $rating ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate the optional reviewCount field. * * @param int|null $reviewCount Raw review count, or null. * * @throws InvalidArgumentException When `$reviewCount` is negative. */ private static function normalizeReviewCount( ?int $reviewCount ): ?int { if ( $reviewCount === null ) { return null; } if ( $reviewCount < 0 ) { $message = sprintf( 'ProductSnapshot reviewCount must be non-negative; got %d.', $reviewCount ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $reviewCount; } /** * Validate and normalize the categories list. * * @param array $categories Raw categories list. * * @return list * * @throws InvalidArgumentException When any entry is not a * string or is empty after * trimming. */ private static function normalizeCategories( array $categories ): array { $normalized = array(); foreach ( $categories as $index => $category ) { if ( ! is_string( $category ) ) { $message = sprintf( 'ProductSnapshot categories[%s] must be a string.', (string) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $category ); if ( $trimmed === '' ) { $message = sprintf( 'ProductSnapshot categories[%s] must be a non-empty string.', (string) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $trimmed; } return $normalized; } /** * Validate and normalize the attributes map. * * Keys MUST be non-empty strings and values MUST be scalar or * null. Nested arrays / objects are rejected because §7.4 * expects a flat attribute surface. * * @param array $attributes Raw attribute map. * * @return array * * @throws InvalidArgumentException When any key is not a * non-empty string or any * value is not scalar / null. */ private static function normalizeAttributes( array $attributes ): array { $normalized = array(); foreach ( $attributes as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'ProductSnapshot attributes keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmedKey = trim( $key ); if ( $trimmedKey === '' ) { $message = 'ProductSnapshot attributes keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $value !== null && ! is_scalar( $value ) ) { $message = sprintf( 'ProductSnapshot attributes["%s"] must be scalar or null; got %s.', $trimmedKey, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmedKey ] = $value; } return $normalized; } /** * Validate the rawMeta map's keys. * * Values are NOT validated because §7.4 expressly preserves * provider-native data here for downstream debugging; the * rendering layer is forbidden from branching on contents, so * shape constraints would only obstruct legitimate use. * * @param array $rawMeta Raw metadata map. * * @return array * * @throws InvalidArgumentException When any key is not a string. */ private static function normalizeRawMeta( array $rawMeta ): array { $normalized = array(); foreach ( $rawMeta as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'ProductSnapshot rawMeta keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $key ] = $value; } return $normalized; } /** * Normalize a timestamp to UTC so equality checks and * round-trips do not depend on the caller's timezone. * * @param DateTimeImmutable $value Timestamp to normalize. */ private static function normalizeToUtc( DateTimeImmutable $value ): DateTimeImmutable { return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } }