expectAssocArray( $decoded, 'response', null ); $hitsRaw = $payload['hits'] ?? null; if ( ! is_array( $hitsRaw ) ) { throw $this->responseException( 'Yahoo!ショッピング search response is missing the "hits" array.', array( 'field' => 'hits', 'reason' => 'missing_hits_array', ) ); } $snapshots = array(); $index = 0; foreach ( array_values( $hitsRaw ) as $hit ) { $snapshots[] = $this->buildSnapshot( $hit, $observedAt, $index ); ++$index; } $total = null; if ( array_key_exists( 'totalResultsAvailable', $payload ) && is_int( $payload['totalResultsAvailable'] ) && $payload['totalResultsAvailable'] >= 0 ) { $total = $payload['totalResultsAvailable']; } $hasMore = $this->deriveHasMore( $payload, $snapshots, $query ); $rawMeta = $this->buildRawMeta( $payload ); return new SearchResult( self::PROVIDER_KEY, $snapshots, $query->page(), $query->perPage(), $total, $hasMore, $rawMeta ); } /** * Decode the exact matching hit from a lookup-via-search response into a * {@see ProductSnapshot}, raising * {@see ProductNotFoundException} when the payload's `hits` * array is empty. * * Per §9.3 the lookup path issues a seller-scoped search and filters the * result set by exact `hits[].code` match. An empty `hits` array, or a * non-empty array without the expected code, means the upstream did not * return the requested product. * * @param mixed $decoded JSON-decoded response body. * @param ProviderProductId $expectedId Expected identifier. * @param DateTimeImmutable $observedAt Observation timestamp. * * @throws ProviderResponseException|ProductNotFoundException When * the payload is not shaped like a商品検索 v3 response, when * the exact matching hit fails the cross-provider snapshot * contract, or when the response carries no exact matching hit. */ public function normalizeProduct( $decoded, ProviderProductId $expectedId, DateTimeImmutable $observedAt ): ProductSnapshot { $payload = $this->expectAssocArray( $decoded, 'response', null ); $hitsRaw = $payload['hits'] ?? null; if ( ! is_array( $hitsRaw ) ) { throw $this->responseException( 'Yahoo!ショッピング lookup response is missing the "hits" array.', array( 'field' => 'hits', 'reason' => 'missing_hits_array', ) ); } if ( $hitsRaw === array() ) { throw new ProductNotFoundException( self::PROVIDER_KEY, sprintf( 'Yahoo!ショッピング lookup did not match any product for "%s".', $expectedId->value() ), array( 'provider_product_id' => $expectedId->value(), 'feature' => 'lookup', ) ); } foreach ( array_values( $hitsRaw ) as $index => $hit ) { if ( ! is_array( $hit ) ) { continue; } $code = $hit['code'] ?? null; if ( is_string( $code ) && $code === $expectedId->value() ) { return $this->buildSnapshot( $hit, $observedAt, $index ); } } throw new ProductNotFoundException( self::PROVIDER_KEY, sprintf( 'Yahoo!ショッピング lookup did not match any product for "%s".', $expectedId->value() ), array( 'provider_product_id' => $expectedId->value(), 'feature' => 'lookup', ) ); } /** * Translate a single decoded hit map into a * {@see ProductSnapshot}. * * @param mixed $hit Decoded hit entry. * @param DateTimeImmutable $observedAt 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( $hit, DateTimeImmutable $observedAt, int $index ): ProductSnapshot { $item = $this->expectAssocArray( $hit, 'hits[]', $index ); $code = $this->requireString( $item, 'code', $index ); $name = $this->requireString( $item, 'name', $index ); $detail = $this->requireString( $item, 'url', $index ); $priceField = $item['price'] ?? null; if ( ! is_int( $priceField ) && ! ( is_string( $priceField ) && $priceField !== '' ) ) { throw $this->responseException( sprintf( 'Yahoo!ショッピング hits[%d].price must be an integer or numeric string.', $index ), array( 'field' => 'hits[].price', 'index' => $index, 'reason' => 'missing_or_invalid_price', ) ); } $priceAmount = is_int( $priceField ) ? (string) $priceField : $priceField; $description = $this->optionalString( $item, 'description' ); $primaryImage = $this->extractImageUrl( $item ); $brand = $this->extractBrandName( $item ); $availability = $this->deriveAvailability( $item ); try { $price = new PriceInfo( $priceAmount, self::CURRENCY, null, $observedAt, true, false, null ); } catch ( InvalidArgumentException $error ) { throw $this->responseException( sprintf( 'Yahoo!ショッピング hits[%d].price could not be normalized: %s', $index, $error->getMessage() ), array( 'field' => 'hits[].price', 'index' => $index, 'reason' => 'price_normalization_failed', ), $error ); } try { return new ProductSnapshot( self::PROVIDER_KEY, new ProviderProductId( self::PROVIDER_KEY, $code ), $name, $brand, $description, $detail, null, $primaryImage, array(), $price, $availability, null, null, array(), array(), $observedAt, $item ); } catch ( InvalidArgumentException $error ) { throw $this->responseException( sprintf( 'Yahoo!ショッピング hits[%d] could not be normalized: %s', $index, $error->getMessage() ), array( 'field' => 'hits[]', 'index' => $index, 'reason' => 'snapshot_normalization_failed', ), $error ); } } /** * Translate the upstream's `inStock` boolean into an * {@see AvailabilityInfo}. Missing / non-boolean values * degrade to {@see AvailabilityInfo::unknown()}. * * @param array $item Decoded hit map. */ private function deriveAvailability( array $item ): AvailabilityInfo { $inStock = $item['inStock'] ?? null; if ( $inStock === true ) { return new AvailabilityInfo( AvailabilityInfo::STATUS_IN_STOCK, null, true ); } if ( $inStock === false ) { return new AvailabilityInfo( AvailabilityInfo::STATUS_OUT_OF_STOCK, null, false ); } return AvailabilityInfo::unknown(); } /** * Synthesize {@see SearchResult::hasMore()} from the * upstream's pagination axes. * * Preferred derivation: `firstResultPosition + * totalResultsReturned - 1 < totalResultsAvailable` when all * three axes are present integers. * * Fallback derivation: when the position axes are missing, * the normalizer reports "has more" when the page produced * exactly `query.perPage()` items. * * @param array $payload Response envelope. * @param list $snapshots Decoded snapshots. * @param SearchQuery $query Original query. */ private function deriveHasMore( array $payload, array $snapshots, SearchQuery $query ): bool { $first = $payload['firstResultPosition'] ?? null; $returned = $payload['totalResultsReturned'] ?? null; $total = $payload['totalResultsAvailable'] ?? null; if ( is_int( $first ) && is_int( $returned ) && is_int( $total ) ) { return ( $first + $returned - 1 ) < $total; } return $query->perPage() > 0 && count( $snapshots ) >= $query->perPage(); } /** * Build the {@see SearchResult::rawMeta()} payload from the * upstream's pagination axes. * * @param array $payload Response envelope. * * @return array */ private function buildRawMeta( array $payload ): array { $rawMeta = array(); foreach ( array( 'totalResultsAvailable', 'totalResultsReturned', 'firstResultPosition', ) as $field ) { if ( array_key_exists( $field, $payload ) ) { $rawMeta[ $field ] = $payload[ $field ]; } } return $rawMeta; } /** * Extract the primary image URL from a hit map, preferring * `image.medium` and falling back to `image.small`. * * Returns null when neither axis is present as a non-empty * string. The cross-provider snapshot constructor enforces * the http(s) scheme guard, so a malformed URL surfaces as a * snapshot-construction failure. * * @param array $item Decoded hit map. */ private function extractImageUrl( array $item ): ?string { $image = $item['image'] ?? null; if ( ! is_array( $image ) ) { return null; } foreach ( array( 'medium', 'small' ) as $size ) { if ( ! array_key_exists( $size, $image ) ) { continue; } $value = $image[ $size ]; if ( ! is_string( $value ) ) { continue; } $trimmed = trim( $value ); if ( $trimmed !== '' ) { return $trimmed; } } return null; } /** * Extract the brand name from a hit map, preferring * `brand.name` and falling back to `seller.name`. * * @param array $item Decoded hit map. */ private function extractBrandName( array $item ): ?string { foreach ( array( 'brand', 'seller' ) as $key ) { if ( ! array_key_exists( $key, $item ) ) { continue; } $entry = $item[ $key ]; if ( ! is_array( $entry ) ) { continue; } $name = $entry['name'] ?? null; if ( ! is_string( $name ) ) { continue; } $trimmed = trim( $name ); if ( $trimmed !== '' ) { return $trimmed; } } return null; } /** * Extract a required string field from a hit map. * * @param array $item Decoded hit 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( 'Yahoo!ショッピング hits[%d].%s is missing.', $index, $field ), array( 'field' => sprintf( 'hits[].%s', $field ), 'index' => $index, 'reason' => 'missing_required_field', ) ); } $value = $item[ $field ]; if ( ! is_string( $value ) ) { throw $this->responseException( sprintf( 'Yahoo!ショッピング hits[%d].%s must be a string.', $index, $field ), array( 'field' => sprintf( 'hits[].%s', $field ), 'index' => $index, 'reason' => 'invalid_field_type', ) ); } $trimmed = trim( $value ); if ( $trimmed === '' ) { throw $this->responseException( sprintf( 'Yahoo!ショッピング hits[%d].%s must be a non-empty string.', $index, $field ), array( 'field' => sprintf( 'hits[].%s', $field ), 'index' => $index, 'reason' => 'empty_required_field', ) ); } return $trimmed; } /** * Extract an optional string field from a hit map. * * Returns null when the field is missing, null, the empty * string after trimming, or not a string at all. Non-string * values degrade to null because the cross-provider snapshot * contract treats every optional field as nullable. * * @param array $item Decoded hit 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; } /** * Cast a `mixed` value to an `array`, raising * {@see ProviderResponseException} when the cast does not * apply. * * @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( 'Yahoo!ショッピング %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 ); } }