httpClient = $httpClient; $this->requestFactory = $requestFactory; $this->responseNormalizer = $responseNormalizer; $this->errorMapper = $errorMapper; $this->affiliateLinkBuilder = $affiliateLinkBuilder; $this->clock = $clock; } /** * Stable §4 provider key. */ public function key(): string { return self::PROVIDER_KEY; } /** * Human-readable display name per §6.1. */ public function label(): string { return self::LABEL; } /** * Capabilities snapshot per §7.9 / §9.2. * * Constructed fresh on each call rather than cached so a * future per-deployment capability variant (some Rakuten * tiers disable affiliate links) can branch on * {@see RakutenCredentials} state without restructuring the * provider. */ public function capabilities(): ProviderCapabilities { return new ProviderCapabilities( true, // supportsSearch. true, // supportsLookup. true, // supportsMultipleImages. true, // supportsPrice. true, // supportsAvailability. true, // supportsRating. true, // supportsCategories. true, // supportsSorts. true, // supportsAffiliateLinkBuild. true, // supportsHealthCheck. false // supportsRateLimitInspection. ); } /** * Report whether the named §8 feature key is supported. * * Delegates to {@see ProviderCapabilities::supportsFeature()} * on the result of {@see self::capabilities()} so the two * surfaces stay consistent without duplication, per the §6.1 * binding rule. * * @param string $feature §8 feature key. * * @throws InvalidArgumentException When `$feature` is not in * the §8 closed set. */ public function supportsFeature( string $feature ): bool { return $this->capabilities()->supportsFeature( $feature ); } /** * Probe the upstream and return a {@see ProviderHealthStatus} * snapshot per §7.10. * * The probe issues a minimal `keyword=a&hits=1` search and * branches on the outcome: * * - 2xx → {@see ProviderHealthStatus::healthy()}. * - {@see HttpClientException} (transport failure) → * `STATUS_UNREACHABLE` with `reachable = false`. * - {@see AuthenticationException} → * `STATUS_INVALID_CREDENTIALS`. * - {@see RateLimitExceededException} → * `STATUS_RATE_LIMITED`. * - {@see TemporaryUnavailableException} → * `STATUS_DEGRADED` with `reachable = true`. * - Anything else → `STATUS_DEGRADED` carrying the * exception message on the snapshot's summary axis. * * Per the §6.1 contract this method MUST NOT throw on * application-level failures — every observable outcome is * encoded as a structured snapshot so the diagnostics surface * can render it without `try`/`catch`. */ public function healthCheck(): ProviderHealthStatus { $now = $this->clock->now(); try { $query = new SearchQuery( 'a', 1, 1, null, null, null, null, array(), null, null, 'health_check' ); $request = $this->requestFactory->buildSearchRequest( $query ); $response = $this->httpClient->send( $request ); if ( $response->isSuccessful() ) { return ProviderHealthStatus::healthy( $now ); } $exception = $this->errorMapper->map( $response ); return $this->translateExceptionToHealth( $exception, true, $now ); } catch ( HttpClientException $error ) { return new ProviderHealthStatus( ProviderHealthStatus::STATUS_UNREACHABLE, false, false, false, $error->getMessage() === '' ? null : $error->getMessage(), $now, array( 'reason' => 'transport_failure' ) ); } } /** * Mint an affiliate-tracked URL per §6.1. * * Delegates to {@see RakutenAffiliateLinkBuilder} verbatim; * see that class's docblock for the route-selection logic and * the no-tracking passthrough behavior. * * @param ProductSnapshot $snapshot Snapshot whose detail URL * should be tracked. * @param AffiliateContext $context Per-request affiliate * context. */ public function buildAffiliateLink( ProductSnapshot $snapshot, AffiliateContext $context ): AffiliateLinkResult { return $this->affiliateLinkBuilder->build( $snapshot, $context ); } /** * Execute a keyword search per §6.2. * * Lifecycle: * * 1. Build the request via the request factory. Provider- * native input violations (per-page cap exceeded, * unsupported sort token) raise * {@see \Affilun\Domain\Provider\Exception\ProviderRequestException} * from the factory and propagate verbatim. * * 2. Send the request via the {@see HttpClient}. Transport * failures raise {@see HttpClientException}, which this * method translates to * {@see TemporaryUnavailableException}. * * 3. On non-2xx, hand the response to the error mapper and * re-throw the produced §10.2 exception. * * 4. On 2xx, decode the body via {@see HttpResponse::jsonBody()} * and hand the decoded value to the response normalizer. * Body-decode and shape failures surface as * {@see ProviderResponseException}. * * @param SearchQuery $query Normalized cross-provider query. * * @throws ProviderException When any §10.2 failure mode applies. */ public function searchProducts( SearchQuery $query ): SearchResult { $now = $this->clock->now(); $request = $this->requestFactory->buildSearchRequest( $query ); $response = $this->dispatch( $request ); if ( ! $response->isSuccessful() ) { throw $this->errorMapper->map( $response ); } $decoded = $this->decodeBody( $response ); return $this->responseNormalizer->normalizeSearchResult( $decoded, $query, $now ); } /** * Look up a single product by {@see ProviderProductId} per * §6.3. * * Lifecycle mirrors {@see self::searchProducts()} except step * 4 calls {@see RakutenResponseNormalizer::normalizeFirstItem()} * — the normalizer returns null for an empty `Items` array, * which this method translates into * {@see ProductNotFoundException} carrying the looked-up id * on the exception context per §6.3. * * @param ProviderProductId $id Identifier whose detail * snapshot should be fetched. * * @throws ProviderException|ProductNotFoundException When any §10.2 * failure mode applies, including * {@see ProductNotFoundException} when the * upstream returned an empty `Items` array * per §6.3. */ public function getProduct( ProviderProductId $id ): ProductSnapshot { $now = $this->clock->now(); $request = $this->requestFactory->buildLookupRequest( $id ); $response = $this->dispatch( $request ); if ( ! $response->isSuccessful() ) { throw $this->errorMapper->map( $response ); } $decoded = $this->decodeBody( $response ); $snapshot = $this->responseNormalizer->normalizeFirstItem( $decoded, $now ); if ( $snapshot === null ) { throw new ProductNotFoundException( self::PROVIDER_KEY, sprintf( 'Rakuten lookup did not match any product for "%s".', $id->value() ), array( 'provider_product_id' => $id->value(), 'feature' => 'lookup', ) ); } return $snapshot; } /** * Dispatch a request through the HTTP client and translate * transport failures into §10.2 exceptions. * * Centralizes the {@see HttpClientException} → * {@see TemporaryUnavailableException} translation so search / * lookup / health-check share a single seam. The translated * exception carries the originating request's URL host and * method on the context map for diagnostic purposes — without * carrying the full URL or the `accessKey` header per §10.1. * * @param \Affilun\Domain\Http\HttpRequest $request Request to dispatch. * * @throws TemporaryUnavailableException When the transport * layer reports a * failure. */ private function dispatch( \Affilun\Domain\Http\HttpRequest $request ): HttpResponse { try { return $this->httpClient->send( $request ); } catch ( HttpClientException $error ) { throw new TemporaryUnavailableException( self::PROVIDER_KEY, sprintf( 'Rakuten request failed at the transport layer: %s', $error->getMessage() ), array( 'http_method' => $request->method(), 'reason' => 'transport_failure', ), $error ); } } /** * Decode the JSON body of a successful Rakuten response. * * Centralizes the {@see HttpResponse::jsonBody()} → * {@see ProviderResponseException} translation so search / * lookup share a single seam. An empty body or a malformed * JSON body raises {@see ProviderResponseException} carrying * the parse-error context per §10. * * @param HttpResponse $response Response whose body to decode. * * @return mixed Decoded JSON value. * * @throws ProviderResponseException When the body cannot be * decoded as JSON. */ private function decodeBody( HttpResponse $response ) { try { return $response->jsonBody(); } catch ( InvalidArgumentException $error ) { throw new ProviderResponseException( self::PROVIDER_KEY, sprintf( 'Rakuten response body could not be decoded as JSON: %s', $error->getMessage() ), array( 'reason' => 'json_decode_failed', 'status_code' => $response->statusCode(), ), $error ); } } /** * Translate a §10.2 exception into a {@see ProviderHealthStatus} * snapshot for the {@see self::healthCheck()} path. * * The probe path branches the response through the error * mapper to produce a structured §10.2 exception; this method * folds the resulting exception into the corresponding * `STATUS_*` token. The `reachable` axis is supplied by the * caller so a transport failure ("we never got a response") * and a 401 ("we got a response, just not a useful one") can * surface different `reachable` values from the same helper. * * @param ProviderException $exception Exception produced by * the error mapper. * @param bool $reachable Whether the upstream * responded at all. * @param \DateTimeImmutable $now Probe timestamp. */ private function translateExceptionToHealth( ProviderException $exception, bool $reachable, \DateTimeImmutable $now ): ProviderHealthStatus { $message = $exception->getMessage() === '' ? null : $exception->getMessage(); $details = array( 'error_code' => $exception->errorCode(), ); if ( $exception instanceof AuthenticationException ) { return new ProviderHealthStatus( ProviderHealthStatus::STATUS_INVALID_CREDENTIALS, false, $reachable, false, $message, $now, $details ); } if ( $exception instanceof RateLimitExceededException ) { return new ProviderHealthStatus( ProviderHealthStatus::STATUS_RATE_LIMITED, true, $reachable, true, $message, $now, $details ); } if ( $exception instanceof TemporaryUnavailableException ) { return new ProviderHealthStatus( ProviderHealthStatus::STATUS_DEGRADED, true, $reachable, false, $message, $now, $details ); } // Fall-through covers ProviderRequestException / // ProviderResponseException / any other §10.2 subclass. // These are degraded states rather than full outages — the // upstream answered, but in a way the cross-provider model // cannot interpret as healthy. return new ProviderHealthStatus( ProviderHealthStatus::STATUS_DEGRADED, true, $reachable, false, $message, $now, $details ); } }