registryOverride = $registryOverride; $this->httpClientOverride = $httpClientOverride; $this->clockOverride = $clockOverride; } /** * Bind the {@see ProviderRegistry} resolved from settings. * * See the file-level docblock for the per-provider construction * rules, the canonical key order * (`amazon`, `rakuten`, `yahoo_shopping`), and the recovery * posture when settings load or per-provider construction * throws. * * @param ServiceProviderRegistry $registry Registry driving * this bootstrap pass. * * @throws LogicException When the persistence provider has not * registered before this one. */ public function register( ServiceProviderRegistry $registry ): void { if ( $this->registryOverride !== null ) { $registry->bind( self::BINDING_PROVIDER_REGISTRY, $this->registryOverride ); return; } $optionStore = $this->resolveOptionStore( $registry ); $repository = new OptionStoreSettingsRepository( $optionStore ); $providers = $this->buildProviders( $repository ); $registry->bind( self::BINDING_PROVIDER_REGISTRY, new ProviderRegistry( $providers ) ); } /** * No-op — this provider only publishes a registry binding and * has no hook wiring of its own. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { unset( $hooks ); } /** * Resolve {@see OptionStore} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not an * {@see OptionStore}. */ private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { $message = 'ProviderServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing OptionStore binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $store = $registry->get( $key ); if ( ! $store instanceof OptionStore ) { $message = sprintf( 'ProviderServiceProvider expected OptionStore under "%s"; got %s.', $key, is_object( $store ) ? get_class( $store ) : gettype( $store ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $store; } /** * Build the canonical-order provider map from the §13 settings. * * Settings load failures are caught at the {@see Throwable} * level and yield an empty providers map; per-provider * construction failures are caught at the * {@see InvalidArgumentException} level and yield a skipped * provider while the others still register. * * @param SettingsRepository $repository Settings repository. * * @return array */ private function buildProviders( SettingsRepository $repository ): array { try { $settings = $repository->load(); } catch ( Throwable $unused ) { unset( $unused ); return array(); } $providers = array(); $amazon = $this->buildAmazonProvider( $settings ); if ( $amazon !== null ) { $providers[ AmazonCreatorsApiProvider::PROVIDER_KEY ] = $amazon; } $rakuten = $this->buildRakutenProvider( $settings ); if ( $rakuten !== null ) { $providers[ RakutenProvider::PROVIDER_KEY ] = $rakuten; } $yahoo = $this->buildYahooProvider( $settings ); if ( $yahoo !== null ) { $providers[ YahooShoppingProvider::PROVIDER_KEY ] = $yahoo; } $mercari = $this->buildMercariProvider( $settings ); if ( $mercari !== null ) { $providers[ MercariProvider::PROVIDER_KEY ] = $mercari; } return $providers; } /** * Construct the {@see AmazonCreatorsApiProvider} when the * §13.4 Amazon Creators API credentials are configured, else * return null. * * **KI-019 Phase B (ADR-0022)**: 旧 PA-API 5.0 ベースの * `AmazonProvider` を {@see AmazonCreatorsApiProvider} に完全置換。 * Creators API は OAuth 2.0 (Credential ID / Credential Secret / * Version / Partner Tag / Marketplace) で認証し、`partnerTag` * + `marketplace` を全リクエストに stamp する。 * * 構築手順: * * 1. `$settings->provider('amazon')` から * {@see ProviderCredentials} を取り出し、enabled かつ * 設定済みの場合のみ続行 (空欄は "unconfigured" として * provider を bind しない)。 * * 2. 4 つの user-editable secret (`credential_id` / * `credential_secret` / `version` / `partner_tag`) を読み出し、 * marketplace は affilun 固定値 `www.amazon.co.jp` を使って * {@see AmazonCreatorsApiCredentials} を構築。validation * 失敗 (空 / 不正な version / 不正な marketplace) は * {@see InvalidArgumentException} を投げるので、その場合は * provider を bind せず null を返す (degraded path、 * diagnostics 側で `unconfigured` として表示される)。 * * 3. {@see AmazonCreatorsApiTokenProvider} を構築。WordPress * object cache adapter は Phase B MVP では injected しない * ので、in-memory cache のみで動作する (1 リクエスト内では * token cache が効くが、リクエスト跨ぎで毎回 token を * 取り直す。これは Amazon の rate limit 設計上問題ない * 範囲。WP object cache adapter の wiring は別 slice で * 対応)。 * * 4. {@see AmazonCreatorsApiRequestFactory} / * {@see AmazonCreatorsApiResponseNormalizer} / * {@see AmazonCreatorsApiErrorMapper} / * {@see AmazonCreatorsApiAffiliateLinkBuilder} を順に構築し、 * これらと共に * {@see AmazonCreatorsApiProvider} を返す。 * * @param PluginSettings $settings §13 settings snapshot. */ private function buildAmazonProvider( PluginSettings $settings ): ?AmazonCreatorsApiProvider { $creds = $settings->provider( 'amazon' ); if ( $creds === null || ! $creds->enabled() || ! $creds->isConfigured() ) { return null; } $credentialId = (string) $creds->secret( 'credential_id' ); $credentialSecret = (string) $creds->secret( 'credential_secret' ); $version = (string) $creds->secret( 'version' ); $partnerTag = (string) $creds->secret( 'partner_tag' ); $marketplace = ProviderCredentials::AMAZON_MARKETPLACE; try { $credentials = new AmazonCreatorsApiCredentials( $credentialId, $credentialSecret, $version, $partnerTag, $marketplace ); } catch ( InvalidArgumentException $unused ) { unset( $unused ); return null; } $clock = $this->resolveClock(); $httpClient = $this->resolveHttpClient(); $tokenProvider = new AmazonCreatorsApiTokenProvider( $httpClient, $clock ); $requestFactory = new AmazonCreatorsApiRequestFactory( $credentials, $tokenProvider ); $responseNormalizer = new AmazonCreatorsApiResponseNormalizer(); $errorMapper = new AmazonCreatorsApiErrorMapper(); $affiliateLinkBuilder = new AmazonCreatorsApiAffiliateLinkBuilder( $credentials ); return new AmazonCreatorsApiProvider( $credentials, $httpClient, $tokenProvider, $requestFactory, $responseNormalizer, $errorMapper, $affiliateLinkBuilder, $clock ); } /** * Construct the {@see RakutenProvider} when the §13.4 Rakuten * credentials are configured, else return null. Rakuten's * current API requires an access key in addition to the * application and affiliate identifiers. * * @param PluginSettings $settings §13 settings snapshot. */ private function buildRakutenProvider( PluginSettings $settings ): ?RakutenProvider { $creds = $settings->provider( 'rakuten' ); if ( $creds === null || ! $creds->enabled() || ! $creds->isConfigured() ) { return null; } $applicationId = (string) $creds->secret( 'application_id' ); $accessKey = (string) $creds->secret( 'access_key' ); $affiliateId = (string) $creds->secret( 'affiliate_id' ); try { $rakutenCreds = new RakutenCredentials( $applicationId, $accessKey, $affiliateId ); } catch ( InvalidArgumentException $unused ) { unset( $unused ); return null; } $clock = $this->resolveClock(); return new RakutenProvider( $this->resolveHttpClient(), new RakutenRequestFactory( $rakutenCreds, $this->resolveSiteOrigin() ), new RakutenResponseNormalizer(), new RakutenErrorMapper(), new RakutenAffiliateLinkBuilder(), $clock ); } /** * Resolve the public WordPress origin used by Rakuten's request * context check. */ private function resolveSiteOrigin(): string { return function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'https://localhost'; } /** * Construct the {@see YahooShoppingProvider} when the §13.4 * Yahoo!ショッピング credentials are configured, else return null. * * The §9.3 商品検索 v3 endpoint authenticates with the * `appid` query parameter (Yahoo Developer Network Client * ID). ValueCommerce sid / pid are optional but, when both are * configured, are forwarded so Yahoo requests and rendered * links can carry affiliate attribution. Any legacy * `affiliate_parameters` value still present in persisted * settings is ignored. * * @param PluginSettings $settings §13 settings snapshot. */ private function buildYahooProvider( PluginSettings $settings ): ?YahooShoppingProvider { $creds = $settings->provider( 'yahoo_shopping' ); if ( $creds === null || ! $creds->enabled() ) { return null; } $appId = (string) $creds->secret( 'app_id' ); $affiliateSid = self::nullableSecret( (string) $creds->secret( 'affiliate_sid' ) ); $affiliatePid = self::nullableSecret( (string) $creds->secret( 'affiliate_pid' ) ); if ( trim( $appId ) === '' ) { return null; } try { $yahooCreds = new YahooShoppingCredentials( $appId, $affiliateSid, $affiliatePid ); } catch ( InvalidArgumentException $unused ) { unset( $unused ); return null; } $clock = $this->resolveClock(); return new YahooShoppingProvider( $this->resolveHttpClient(), new YahooShoppingRequestFactory( $yahooCreds ), new YahooShoppingResponseNormalizer(), new YahooShoppingErrorMapper(), new YahooShoppingAffiliateLinkBuilder( $yahooCreds ), $clock ); } /** * Normalize an optional settings secret to null when blank. * * @param string $value Raw settings value. */ private static function nullableSecret( string $value ): ?string { $value = trim( $value ); return $value === '' ? null : $value; } /** * Construct the Mercari provider when enabled. * * Mercari has no API / SDK credentials, but the Ambassador `afid` is stored * so rendered links can be affiliate-stamped. * * @param PluginSettings $settings §13 settings snapshot. */ private function buildMercariProvider( PluginSettings $settings ): ?MercariProvider { $creds = $settings->provider( MercariProvider::PROVIDER_KEY ); if ( $creds === null || ! $creds->enabled() ) { return null; } return new MercariProvider( (string) $creds->secret( 'afid' ) ); } /** * Resolve the {@see HttpClient} to forward to constructed * providers. Defaults to {@see WpRemoteHttpClient}. */ private function resolveHttpClient(): HttpClient { return $this->httpClientOverride ?? new WpRemoteHttpClient(); } /** * Resolve the {@see Clock} to forward to constructed providers. * Defaults to {@see SystemClock}. */ private function resolveClock(): Clock { return $this->clockOverride ?? new SystemClock(); } }