*/ private const SUPPORTED_SORTS = array( self::SORT_RELEVANCE => null, self::SORT_PRICE_ASC => '+itemPrice', self::SORT_PRICE_DESC => '-itemPrice', ); /** * Rakuten 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 RakutenCredentials $credentials; /** * Resolved Rakuten Web Service 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; /** * Site origin registered in Rakuten's allowed-websites setting. */ private string $origin; /** * Construct a Rakuten request factory. * * @param RakutenCredentials $credentials Rakuten credentials * to stamp onto every * constructed request. * @param string $origin Site origin registered * with Rakuten. * @param string|null $endpoint Optional explicit * endpoint URL. Defaults * to {@see self::DEFAULT_SEARCH_ENDPOINT} * per §9.2. Tests and * integration paths that * need to point at an * alternate endpoint pass * a non-null value here. */ public function __construct( RakutenCredentials $credentials, string $origin, ?string $endpoint = null ) { $this->credentials = $credentials; $this->origin = self::normalizeOrigin( $origin ); $this->endpoint = $endpoint ?? self::DEFAULT_SEARCH_ENDPOINT; } /** * Translate a cross-provider {@see SearchQuery} into a Rakuten * IchibaItem Search request. * * Validation summary: * * - {@see SearchQuery::perPage()} MUST be `<= 30` * ({@see self::MAX_HITS_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 * Rakuten because the upstream lacks a stable equivalent. * Filters (the structured map) are not forwarded for the * same reason; a future provider extension can pick up * specific filter keys. * * - The `applicationId` and `affiliateId` query parameters * are stamped from {@see self::$credentials} on every * constructed request, regardless of cross-provider * content. The `accessKey` and `Origin` headers are also * always present. * * - `format=json&formatVersion=1` is always set so the response * normalizer can decode the body as a single shape. * * @param SearchQuery $query Normalized cross-provider query. * * @throws ProviderRequestException When the supplied query * violates a Rakuten-native * input constraint * (per-provider page-size cap, * unsupported sort token). */ public function buildSearchRequest( SearchQuery $query ): HttpRequest { if ( $query->perPage() > self::MAX_HITS_PER_PAGE ) { throw new ProviderRequestException( self::PROVIDER_KEY, sprintf( 'Rakuten IchibaItem Search caps perPage at %d; got %d.', self::MAX_HITS_PER_PAGE, $query->perPage() ), array( 'feature' => 'search', 'parameter' => 'perPage', 'requested' => $query->perPage(), 'cap' => self::MAX_HITS_PER_PAGE, ) ); } $wireSort = null; $rawSort = $query->sort(); if ( $rawSort !== null ) { if ( ! array_key_exists( $rawSort, self::SUPPORTED_SORTS ) ) { throw new ProviderRequestException( self::PROVIDER_KEY, sprintf( 'Rakuten provider does not support sort token "%s".', $rawSort ), array( 'feature' => 'search', 'parameter' => 'sort', 'requested' => $rawSort, 'supported' => array_keys( self::SUPPORTED_SORTS ), ) ); } $wireSort = self::SUPPORTED_SORTS[ $rawSort ]; } $parameters = $this->baseParameters(); $parameters['keyword'] = $query->keyword(); $parameters['page'] = $query->page(); $parameters['hits'] = $query->perPage(); $minPrice = $query->minPrice(); if ( $minPrice !== null ) { $parameters['minPrice'] = $minPrice; } $maxPrice = $query->maxPrice(); if ( $maxPrice !== null ) { $parameters['maxPrice'] = $maxPrice; } if ( $wireSort !== null ) { $parameters['sort'] = $wireSort; } return HttpRequest::get( $this->endpoint, $parameters, $this->requestHeaders() ); } /** * Translate a cross-provider {@see ProviderProductId} into a * Rakuten IchibaItem Search request scoped to a single item. * * Per §9.2 ("任意対応 — 単品取得 API が弱い場合は検索結果 * ベース補完でもよい"), this factory implements the lookup * path as a `?itemCode=shop:item-id&hits=1` search rather * than against a dedicated single-item endpoint. The * resulting upstream call returns a one-result `Items` array * the response normalizer can decode the same way as a * normal search hit, and an empty `Items` array — which the * upstream returns when the item code does not match — maps * cleanly to a {@see \Affilun\Domain\Provider\Exception\ProductNotFoundException} * downstream. * * 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 does * NOT validate the value half against Rakuten's `itemCode` * grammar (`shop:item-id`). A malformed value flows * through to the upstream, which rejects it with a * well-defined error envelope the error mapper can * translate. Rejecting locally would force a duplicate * grammar definition in this factory. * * @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(); $parameters['itemCode'] = $id->value(); $parameters['hits'] = 1; $parameters['page'] = 1; return HttpRequest::get( $this->endpoint, $parameters, $this->requestHeaders() ); } /** * 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; } /** * Normalized site origin sent to Rakuten. */ public function origin(): string { return $this->origin; } /** * 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 ); } /** * Build the credential / format 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 { return array( 'format' => 'json', 'formatVersion' => 1, 'applicationId' => $this->credentials->applicationId(), 'affiliateId' => $this->credentials->affiliateId(), ); } /** * Build headers required by Rakuten's current request context. * * @return array */ private function requestHeaders(): array { return array( 'accessKey' => $this->credentials->accessKey(), 'Origin' => $this->origin, ); } /** * Normalize a site URL to an RFC 6454-style origin. * * @param string $origin Site URL or origin to normalize. * * @throws InvalidArgumentException When the URL is not HTTP(S). */ private static function normalizeOrigin( string $origin ): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Infrastructure class must remain usable outside WordPress. $parts = parse_url( trim( $origin ) ); if ( ! is_array( $parts ) || ! isset( $parts['scheme'], $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { throw new InvalidArgumentException( 'RakutenRequestFactory origin must be an HTTP(S) URL.' ); } $normalized = strtolower( $parts['scheme'] ) . '://' . strtolower( $parts['host'] ); if ( isset( $parts['port'] ) ) { $normalized .= ':' . $parts['port']; } return $normalized; } }