*/ private const SUPPORTED_SORTS = array( self::SORT_RELEVANCE => '-score', self::SORT_PRICE_ASC => '+price', self::SORT_PRICE_DESC => '-price', ); /** * Yahoo!ショッピング credentials forwarded to every constructed * request. * * Held by reference rather than as field-by-field copies so a * future credential rotation requires only swapping the DTO * the factory was constructed with — but per §10.1 the * factory itself MUST NOT expose the held DTO through any * public surface. */ private YahooShoppingCredentials $credentials; /** * Resolved Yahoo!ショッピング商品検索 v3 endpoint URL the * factory targets. Held as a string rather than re-derived * per call so the construction-time validation runs once. */ private string $endpoint; /** * Construct a Yahoo!ショッピング request factory. * * @param YahooShoppingCredentials $credentials Yahoo credentials * to stamp onto * every constructed * request. * @param string|null $endpoint Optional explicit * endpoint URL. * Defaults to * {@see self::DEFAULT_SEARCH_ENDPOINT} * per §9.3. */ public function __construct( YahooShoppingCredentials $credentials, ?string $endpoint = null ) { $this->credentials = $credentials; $this->endpoint = $endpoint ?? self::DEFAULT_SEARCH_ENDPOINT; } /** * Translate a cross-provider {@see SearchQuery} into a Yahoo * 商品検索 v3 request. * * Validation summary: * * - {@see SearchQuery::perPage()} MUST be `<= 50` * ({@see self::MAX_RESULTS_PER_PAGE}); a higher value * raises {@see ProviderRequestException}. * * - {@see SearchQuery::sort()}, when non-null, MUST appear * in {@see self::SUPPORTED_SORTS}; any other token raises * {@see ProviderRequestException} carrying the offending * value plus the list of supported tokens for diagnosis. * * - {@see SearchQuery::category()} / * {@see SearchQuery::locale()} / * {@see SearchQuery::currency()} / * {@see SearchQuery::context()} are accepted at the cross- * provider DTO level but deliberately NOT forwarded to * Yahoo because the upstream lacks a stable equivalent. * * - The `appid` query parameter is stamped from * {@see self::$credentials} on every constructed request. * Affiliate parameters are forwarded only when the sid is * configured. * * @param SearchQuery $query Normalized cross-provider query. * * @throws ProviderRequestException When the supplied query * violates a Yahoo-native * input constraint * (per-provider page-size cap, * unsupported sort token). */ public function buildSearchRequest( SearchQuery $query ): HttpRequest { if ( $query->perPage() > self::MAX_RESULTS_PER_PAGE ) { throw new ProviderRequestException( self::PROVIDER_KEY, sprintf( 'Yahoo!ショッピング商品検索 v3 caps perPage at %d; got %d.', self::MAX_RESULTS_PER_PAGE, $query->perPage() ), array( 'feature' => 'search', 'parameter' => 'perPage', 'requested' => $query->perPage(), 'cap' => self::MAX_RESULTS_PER_PAGE, ) ); } $wireSort = $this->resolveSort( $query->sort() ); $parameters = $this->baseParameters(); $parameters['query'] = $query->keyword(); $parameters['results'] = $query->perPage(); $parameters['start'] = ( $query->page() - 1 ) * $query->perPage() + 1; $parameters['sort'] = $wireSort; $minPrice = $query->minPrice(); if ( $minPrice !== null ) { $parameters['price_from'] = $minPrice; } $maxPrice = $query->maxPrice(); if ( $maxPrice !== null ) { $parameters['price_to'] = $maxPrice; } return HttpRequest::get( $this->endpoint, $parameters ); } /** * Translate a cross-provider {@see ProviderProductId} into a * Yahoo!ショッピング商品検索 v3 request scoped to a single * item via seller-scoped search. * * Per §9.3 ("任意対応 — 単品取得が弱い場合は検索結果ベース * 補完でもよい"), this factory implements the lookup path as a * seller-scoped keyword search rather than against a dedicated * single-item endpoint — the upstream does not expose one. Yahoo's * persisted `code` value is shaped as `seller_id_item_code`, so the * factory splits on the first underscore and sends * `seller_id=&query=`. The response normalizer then * selects the hit whose `code` exactly matches the expected provider id. * * Validation: * * - The supplied id MUST belong to this provider per * {@see self::PROVIDER_KEY}. A foreign-provider id raises * {@see ProviderRequestException} carrying the supplied * and expected provider keys for diagnosis. * * - Beyond the provider-key cross-check, the factory only performs a * best-effort split of Yahoo's `seller_item` code. Values that do * not contain an underscore fall back to a normal keyword search for * the full id and are validated by the normalizer's exact-code match. * * @param ProviderProductId $id Identifier whose detail * snapshot should be fetched. * * @throws ProviderRequestException When `$id` belongs to a * provider other than * {@see self::PROVIDER_KEY}. */ public function buildLookupRequest( ProviderProductId $id ): HttpRequest { if ( ! $id->belongsTo( self::PROVIDER_KEY ) ) { throw new ProviderRequestException( self::PROVIDER_KEY, sprintf( 'ProviderProductId belongs to provider "%s", not "%s".', $id->provider(), self::PROVIDER_KEY ), array( 'feature' => 'lookup', 'expected_provider' => self::PROVIDER_KEY, 'actual_provider' => $id->provider(), ) ); } $parameters = $this->baseParameters(); $parts = explode( '_', $id->value(), 2 ); if ( count( $parts ) === 2 && trim( $parts[0] ) !== '' && trim( $parts[1] ) !== '' ) { $parameters['seller_id'] = $parts[0]; $parameters['query'] = $parts[1]; } else { $parameters['query'] = $id->value(); } $parameters['results'] = 10; $parameters['start'] = 1; return HttpRequest::get( $this->endpoint, $parameters ); } /** * Endpoint URL the factory currently targets. * * Exposed so test assertions can correlate a constructed * {@see HttpRequest::url()} back to the configured endpoint * without re-importing the constant. Production code does * not need this accessor — the factory is the single seam * where endpoint URLs live. */ public function endpoint(): string { return $this->endpoint; } /** * Cross-provider sort tokens this factory accepts on * {@see self::buildSearchRequest()}. * * Returned as a positional list in declaration order so admin * UIs that want to render a sort dropdown can iterate without * sorting the keys themselves. * * @return list */ public static function supportedSortTokens(): array { return array_keys( self::SUPPORTED_SORTS ); } /** * Resolve the cross-provider sort token to its wire-level * Yahoo equivalent, falling back to `-score` when the token * is null. * * @param string|null $rawSort Cross-provider sort token, or null. * * @throws ProviderRequestException When `$rawSort` is non-null * and not in * {@see self::SUPPORTED_SORTS}. */ private function resolveSort( ?string $rawSort ): string { if ( $rawSort === null ) { return self::SUPPORTED_SORTS[ self::SORT_RELEVANCE ]; } if ( ! array_key_exists( $rawSort, self::SUPPORTED_SORTS ) ) { throw new ProviderRequestException( self::PROVIDER_KEY, sprintf( 'Yahoo!ショッピング provider does not support sort token "%s".', $rawSort ), array( 'feature' => 'search', 'parameter' => 'sort', 'requested' => $rawSort, 'supported' => array_keys( self::SUPPORTED_SORTS ), ) ); } return self::SUPPORTED_SORTS[ $rawSort ]; } /** * Build the credential / affiliate query-parameter map shared * by every constructed request. * * Keeping the map building behind a private helper keeps the * §10.1 "credential material reads pass through a single * audit point" guarantee at one location. * * @return array */ private function baseParameters(): array { $parameters = array( 'appid' => $this->credentials->clientId(), ); // Yahoo!ショッピング rewards are attributed through ValueCommerce. // Pass the referral base URL as the affiliate_id value; the HTTP // adapter performs the outer query-string encoding. if ( $this->credentials->hasAffiliateCredentials() ) { $parameters['affiliate_type'] = 'vc'; $parameters['affiliate_id'] = YahooShoppingAffiliateLinkBuilder::buildReferralBaseUrl( (string) $this->credentials->affiliateSid(), (string) $this->credentials->affiliatePid() ); } return $parameters; } }