expectAssocArray( $decoded, 'response', null ); $itemsRaw = $payload['Items'] ?? $payload['items'] ?? null; if ( ! is_array( $itemsRaw ) ) { throw $this->responseException( 'Rakuten search response is missing the "Items" array.', array( 'field' => 'Items', 'reason' => 'missing_items_array', ) ); } $snapshots = array(); $index = 0; foreach ( $itemsRaw as $envelope ) { $snapshots[] = $this->normalizeItemEnvelope( $envelope, $now, $index ); ++$index; } $page = $this->extractInt( $payload, 'page', $query->page() ); $perPage = $this->extractInt( $payload, 'hits', $query->perPage() ); $total = array_key_exists( 'count', $payload ) && is_int( $payload['count'] ) ? $payload['count'] : null; $hasMore = $this->deriveHasMore( $payload, $snapshots ); $rawMeta = $this->buildRawMeta( $payload ); return new SearchResult( self::PROVIDER_KEY, $snapshots, $page, $perPage, $total, $hasMore, $rawMeta ); } /** * Decode the first item from a single-result lookup-via-search * response into a {@see ProductSnapshot}, or return null when * the payload's `Items` array is empty. * * Per §9.2 the lookup path issues a `?itemCode=...&hits=1` * search; an empty `Items` array means the upstream confirmed * no product matches the supplied id, which the provider * layer translates into a {@see \Affilun\Domain\Provider\Exception\ProductNotFoundException}. * Returning null from the normalizer rather than raising * lets the provider layer compose its own exception with the * looked-up id in context. * * @param mixed $decoded JSON-decoded response body. * @param DateTimeImmutable $now Observation timestamp. * * @throws ProviderResponseException When the payload is not * shaped like an IchibaItem * Search response or when * the first item fails the * cross-provider snapshot * contract. */ public function normalizeFirstItem( $decoded, DateTimeImmutable $now ): ?ProductSnapshot { $payload = $this->expectAssocArray( $decoded, 'response', null ); $itemsRaw = $payload['Items'] ?? $payload['items'] ?? null; if ( ! is_array( $itemsRaw ) ) { throw $this->responseException( 'Rakuten lookup response is missing the "Items" array.', array( 'field' => 'Items', 'reason' => 'missing_items_array', ) ); } if ( $itemsRaw === array() ) { return null; } // Re-key to a positional list and take the first entry; the // upstream returns positional arrays already, but defensive // re-indexing keeps the normalizer robust against a future // drift to associative shapes. $ordered = array_values( $itemsRaw ); return $this->normalizeItemEnvelope( $ordered[0], $now, 0 ); } /** * Decode a single `{Item: {...}}` envelope into a * {@see ProductSnapshot}. * * Exposed primarily for callers that already have an envelope * in hand (test fixtures, future bulk paths). Production * provider code goes through {@see self::normalizeSearchResult()} * or {@see self::normalizeFirstItem()}. * * @param mixed $envelope JSON-decoded `{Item: {...}}` envelope. * @param DateTimeImmutable $now Observation timestamp. * @param int $index Positional index for diagnostic context. * * @throws ProviderResponseException When the envelope is not * shaped correctly or when * the contained item fails * the cross-provider snapshot * contract. */ public function normalizeItemEnvelope( $envelope, DateTimeImmutable $now, int $index = 0 ): ProductSnapshot { $envelopeAssoc = $this->expectAssocArray( $envelope, 'Items entry', $index ); $itemKey = array_key_exists( 'Item', $envelopeAssoc ) ? 'Item' : ( array_key_exists( 'item', $envelopeAssoc ) ? 'item' : null ); if ( $itemKey === null ) { throw $this->responseException( sprintf( 'Rakuten Items[%d] is missing the "Item" envelope.', $index ), array( 'field' => 'Items[].Item', 'index' => $index, 'reason' => 'missing_item_envelope', ) ); } $item = $this->expectAssocArray( $envelopeAssoc[ $itemKey ], 'Items[].Item', $index ); return $this->buildSnapshot( $item, $now, $index ); } /** * Translate a single decoded `Item` map into a * {@see ProductSnapshot}. * * @param array $item Decoded item map. * @param DateTimeImmutable $now Observation timestamp. * @param int $index Positional index for diagnostic context. * * @throws ProviderResponseException When any required field is * missing / mistyped, or * when the resulting snapshot * constructor rejects the * value. */ private function buildSnapshot( array $item, DateTimeImmutable $now, int $index ): ProductSnapshot { $itemCode = $this->requireString( $item, 'itemCode', $index ); $itemName = $this->requireString( $item, 'itemName', $index ); $itemUrl = $this->requireString( $item, 'itemUrl', $index ); $itemPriceField = $item['itemPrice'] ?? null; if ( ! is_int( $itemPriceField ) && ! ( is_string( $itemPriceField ) && $itemPriceField !== '' ) ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.itemPrice must be an integer or numeric string.', $index ), array( 'field' => 'Items[].Item.itemPrice', 'index' => $index, 'reason' => 'missing_or_invalid_price', ) ); } $itemPrice = is_int( $itemPriceField ) ? (string) $itemPriceField : $itemPriceField; $taxFlagField = $item['taxFlag'] ?? null; if ( ! is_int( $taxFlagField ) ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.taxFlag must be an integer (0 or 1).', $index ), array( 'field' => 'Items[].Item.taxFlag', 'index' => $index, 'reason' => 'missing_or_invalid_tax_flag', ) ); } $isTaxIncluded = $taxFlagField === 1; $shopName = $this->optionalString( $item, 'shopName' ); $itemCaption = $this->optionalString( $item, 'itemCaption' ); $affiliateUrl = $this->optionalString( $item, 'affiliateUrl' ); $rating = $this->optionalRating( $item, 'reviewAverage', $index ); $reviewCount = $this->optionalReviewCount( $item, 'reviewCount', $index ); $primaryImage = null; $galleryImages = array(); if ( array_key_exists( 'mediumImageUrls', $item ) && is_array( $item['mediumImageUrls'] ) ) { $mediumImageUrls = array_values( $item['mediumImageUrls'] ); foreach ( $mediumImageUrls as $imageIndex => $imageEntry ) { $imageUrl = $this->extractImageUrl( $imageEntry, $index, $imageIndex ); if ( $imageUrl === null ) { continue; } if ( $primaryImage === null ) { $primaryImage = $imageUrl; continue; } $galleryImages[] = $imageUrl; } } $availability = $this->deriveAvailability( $item ); $categories = array(); if ( array_key_exists( 'genreId', $item ) ) { $genreId = $item['genreId']; if ( is_int( $genreId ) ) { $categories[] = (string) $genreId; } elseif ( is_string( $genreId ) && $genreId !== '' ) { $categories[] = $genreId; } } try { $price = new PriceInfo( $itemPrice, self::CURRENCY, null, $now, $isTaxIncluded, false, null ); } catch ( InvalidArgumentException $error ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.itemPrice could not be normalized: %s', $index, $error->getMessage() ), array( 'field' => 'Items[].Item.itemPrice', 'index' => $index, 'reason' => 'price_normalization_failed', ), $error ); } try { return new ProductSnapshot( self::PROVIDER_KEY, new ProviderProductId( self::PROVIDER_KEY, $itemCode ), $itemName, $shopName, $itemCaption, $itemUrl, $affiliateUrl, $primaryImage, $galleryImages, $price, $availability, $rating, $reviewCount, $categories, array(), $now, $item ); } catch ( InvalidArgumentException $error ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item could not be normalized: %s', $index, $error->getMessage() ), array( 'field' => 'Items[].Item', 'index' => $index, 'reason' => 'snapshot_normalization_failed', ), $error ); } } /** * Translate the upstream's `availability` integer into an * {@see AvailabilityInfo}. * * Per the Rakuten IchibaItem schema, `1` means "in stock" and * `0` means "out of stock". Anything else (including a missing * field) maps to {@see AvailabilityInfo::unknown()} per the * §7.6 fallback. * * @param array $item Decoded item map. */ private function deriveAvailability( array $item ): AvailabilityInfo { $availability = $item['availability'] ?? null; if ( $availability === 1 ) { return new AvailabilityInfo( AvailabilityInfo::STATUS_IN_STOCK, null, true ); } if ( $availability === 0 ) { return new AvailabilityInfo( AvailabilityInfo::STATUS_OUT_OF_STOCK, null, false ); } return AvailabilityInfo::unknown(); } /** * Synthesize {@see SearchResult::hasMore()} from the response * envelope's pagination axes. * * Preferred derivation: `last < count` when both are integers * — the upstream's `last` is the 1-based index of the last * item on this page, and `count` is the cross-page total. * * Fallback derivation: when `count` is unknown, the * normalizer reports "has more" when the page produced exactly * `hits` items (matching what the request asked for). This * mirrors the cross-provider §7.2 "cursor-style provider" * convention. * * @param array $payload Response envelope. * @param list $snapshots Decoded snapshots. */ private function deriveHasMore( array $payload, array $snapshots ): bool { if ( array_key_exists( 'last', $payload ) && is_int( $payload['last'] ) && array_key_exists( 'count', $payload ) && is_int( $payload['count'] ) ) { return $payload['last'] < $payload['count']; } if ( array_key_exists( 'hits', $payload ) && is_int( $payload['hits'] ) ) { return $payload['hits'] > 0 && count( $snapshots ) >= $payload['hits']; } return false; } /** * Build the {@see SearchResult::rawMeta()} payload from the * upstream's pagination axes. * * Carries `count` / `page` / `pageCount` / `first` / `last` / * `hits` keys verbatim when present. Per §7.2 the rendering * layer treats this map as opaque, so missing keys are * deliberately not synthesized here. * * @param array $payload Response envelope. * * @return array */ private function buildRawMeta( array $payload ): array { $rawMeta = array(); foreach ( array( 'count', 'page', 'pageCount', 'first', 'last', 'hits' ) as $field ) { if ( array_key_exists( $field, $payload ) ) { $rawMeta[ $field ] = $payload[ $field ]; } } return $rawMeta; } /** * Extract a URL from a `mediumImageUrls[i]` entry. * * The Rakuten endpoint shape varies between major versions: * older responses wrap the URL in a `{ "imageUrl": "https://..." }` * object, while newer responses expose the URL string * directly. This helper accepts either shape and returns * null when the entry is malformed beyond either form. * * @param mixed $entry Raw image entry. * @param int $itemIndex Positional index of the parent item. * @param int $imageIndex Positional index of this image entry. * * @throws ProviderResponseException When the entry's `imageUrl` * is present but not a string, * indicating contract drift * beyond the tolerated dual * shape. */ private function extractImageUrl( $entry, int $itemIndex, int $imageIndex ): ?string { if ( is_string( $entry ) ) { $trimmed = trim( $entry ); return $trimmed === '' ? null : $trimmed; } if ( is_array( $entry ) && array_key_exists( 'imageUrl', $entry ) ) { $url = $entry['imageUrl']; if ( is_string( $url ) ) { $trimmed = trim( $url ); return $trimmed === '' ? null : $trimmed; } throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.mediumImageUrls[%d].imageUrl must be a string.', $itemIndex, $imageIndex ), array( 'field' => 'Items[].Item.mediumImageUrls[].imageUrl', 'index' => $itemIndex, 'reason' => 'invalid_image_url', ) ); } return null; } /** * Extract a required string field from an item map. * * Trims surrounding whitespace and rejects the result when the * field is missing, not a string, or empty after trimming. * * @param array $item Decoded item map. * @param string $field Field name. * @param int $index Positional index for diagnostic context. * * @throws ProviderResponseException When the field violates * the per-field rule. */ private function requireString( array $item, string $field, int $index ): string { if ( ! array_key_exists( $field, $item ) ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.%s is missing.', $index, $field ), array( 'field' => sprintf( 'Items[].Item.%s', $field ), 'index' => $index, 'reason' => 'missing_required_field', ) ); } $value = $item[ $field ]; if ( ! is_string( $value ) ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.%s must be a string.', $index, $field ), array( 'field' => sprintf( 'Items[].Item.%s', $field ), 'index' => $index, 'reason' => 'invalid_field_type', ) ); } $trimmed = trim( $value ); if ( $trimmed === '' ) { throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.%s must be a non-empty string.', $index, $field ), array( 'field' => sprintf( 'Items[].Item.%s', $field ), 'index' => $index, 'reason' => 'empty_required_field', ) ); } return $trimmed; } /** * Extract an optional string field from an item map. * * Returns null when the field is missing, null, the empty * string after trimming, or not a string at all. The * "not a string" case deliberately does NOT raise here * because the cross-provider snapshot contract treats every * optional field as nullable; non-string values arrive only * via contract drift on optional axes and degrading to null * is preferable to failing the whole snapshot. * * @param array $item Decoded item map. * @param string $field Field name. */ private function optionalString( array $item, string $field ): ?string { if ( ! array_key_exists( $field, $item ) ) { return null; } $value = $item[ $field ]; if ( ! is_string( $value ) ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } /** * Extract the optional `reviewAverage` field as a decimal * string suitable for {@see ProductSnapshot::rating()}. * * The upstream emits `reviewAverage` as a float (`4.5`); the * cross-provider snapshot expects a decimal string, so this * helper formats with `%g` to preserve the precision the * upstream supplied without introducing trailing zeros. * * @param array $item Decoded item map. * @param string $field Field name. * @param int $index Positional index for diagnostic context. * * @throws ProviderResponseException When the field is present * but not a numeric value. */ private function optionalRating( array $item, string $field, int $index ): ?string { if ( ! array_key_exists( $field, $item ) ) { return null; } $value = $item[ $field ]; if ( $value === null ) { return null; } if ( is_int( $value ) ) { return (string) $value; } if ( is_float( $value ) ) { $formatted = sprintf( '%g', $value ); return $formatted === '' ? null : $formatted; } if ( is_string( $value ) ) { $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.%s must be a number, numeric string, or null.', $index, $field ), array( 'field' => sprintf( 'Items[].Item.%s', $field ), 'index' => $index, 'reason' => 'invalid_rating', ) ); } /** * Extract the optional `reviewCount` field as an integer * suitable for {@see ProductSnapshot::reviewCount()}. * * @param array $item Decoded item map. * @param string $field Field name. * @param int $index Positional index for diagnostic context. * * @throws ProviderResponseException When the field is present * but not an integer or * numeric string. */ private function optionalReviewCount( array $item, string $field, int $index ): ?int { if ( ! array_key_exists( $field, $item ) ) { return null; } $value = $item[ $field ]; if ( $value === null ) { return null; } if ( is_int( $value ) ) { return $value < 0 ? null : $value; } if ( is_string( $value ) && ctype_digit( $value ) ) { return (int) $value; } throw $this->responseException( sprintf( 'Rakuten Items[%d].Item.%s must be a non-negative integer.', $index, $field ), array( 'field' => sprintf( 'Items[].Item.%s', $field ), 'index' => $index, 'reason' => 'invalid_review_count', ) ); } /** * Extract a positive integer pagination axis from the response * envelope, falling back to the supplied default when the * field is missing or invalid. * * @param array $payload Response envelope. * @param string $field Field name. * @param int $fallback Fallback value. */ private function extractInt( array $payload, string $field, int $fallback ): int { if ( ! array_key_exists( $field, $payload ) ) { return $fallback; } $value = $payload[ $field ]; if ( is_int( $value ) && $value >= 1 ) { return $value; } if ( is_string( $value ) && ctype_digit( $value ) && $value !== '' ) { $cast = (int) $value; if ( $cast >= 1 ) { return $cast; } } return $fallback; } /** * Cast a `mixed` value to an `array`, raising * {@see ProviderResponseException} when the cast does not * apply. * * Used at every shape-checking boundary so the response-shape * failure mode lives at one location and carries consistent * diagnostic context. * * @param mixed $value Value under test. * @param string $label Diagnostic label for the failing field. * @param int|null $index Positional index, or null for the * top-level response. * * @return array * * @throws ProviderResponseException When `$value` is not an * associative array. */ private function expectAssocArray( $value, string $label, ?int $index ): array { if ( ! is_array( $value ) ) { throw $this->responseException( sprintf( 'Rakuten %s must be an associative array; got %s.', $label, gettype( $value ) ), array( 'field' => $label, 'index' => $index, 'reason' => 'invalid_envelope_type', ) ); } return $value; } /** * Construct a {@see ProviderResponseException} with this * normalizer's provider key and the supplied diagnostic * context. * * @param string $message Human-readable summary. * @param array $context Structured context map. * @param \Throwable|null $previous Underlying cause, or null. */ private function responseException( string $message, array $context, ?\Throwable $previous = null ): ProviderResponseException { return new ProviderResponseException( self::PROVIDER_KEY, $message, $context, $previous ); } }