..., 'status' => ...)` * shim). The key is therefore never serialized onto the * wire response. * * Resolution policy: * * - Every collaborator is optional; omitting one causes the * provider to construct the production default during * register(). Tests inject synthetic collaborators one axis * at a time via the constructor overrides. * * - Resolution is eager at register() time so a misconfigured * bootstrap pipeline (Persistence or REST provider missing * from the order above) surfaces during * {@see ServiceProviderRegistry::registerAll()} rather than * at the first REST request. * * - The composed controller is cached on this provider so the * route-callback closures can capture the same instance * without re-resolving the registry binding. * * @package Affilun\Presentation\Rest */ declare(strict_types=1); namespace Affilun\Presentation\Rest; use Affilun\Application\Product\CloudProductSearchKeywordsExtractor; use Affilun\Application\Product\FallbackProductSearchKeywordsExtractor; use Affilun\Application\Product\UseCase\GetProductUseCase; use Affilun\Application\Product\UseCase\ListProductsUseCase; use Affilun\Application\Product\UseCase\SaveProductUseCase; use Affilun\Application\Product\UseCase\SearchProductsUseCase; use Affilun\Application\Provider\ProviderRegistry; use Affilun\Application\Settings\OptionStoreSettingsRepository; use Affilun\Bootstrap\HookRegistrar; use Affilun\Bootstrap\ServiceProvider; use Affilun\Bootstrap\ServiceProviderRegistry; use Affilun\Domain\Affiliate\AffiliateContext; use Affilun\Domain\Affiliate\AffiliateLinkResult; use Affilun\Domain\Clock; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Domain\Provider\ProviderCapabilities; use Affilun\Domain\Provider\ProviderHealthStatus; use Affilun\Domain\Provider\SearchProviderInterface; use Affilun\Domain\Search\SearchQuery; use Affilun\Domain\Search\SearchResult; use Affilun\Infrastructure\Clock\SystemClock; use Affilun\Infrastructure\Persistence\OptionStore; use Affilun\Infrastructure\Persistence\PersistenceServiceProvider; use Affilun\Infrastructure\Persistence\ProductRepository; use Affilun\Infrastructure\Persistence\ProductUrlRepository; use Affilun\Presentation\Rest\Controller\ProductsController; use Affilun\Presentation\Rest\Controller\RestRequestAdapter; use Affilun\Presentation\Rest\Mapper\ProductDetailMapper; use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper; use Affilun\Presentation\Rest\Mapper\SearchResultItemMapper; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use Affilun\Presentation\Rest\Response\ProviderExceptionMapper; use Affilun\Presentation\Rest\Routing\RouteDefinition; use Affilun\Presentation\Rest\Routing\RouteRegistrar; use Affilun\Presentation\Rest\Validation\RequestValidator; use Closure; use DateTimeImmutable; use DateTimeZone; use LogicException; /** * Binds the four §8.1-§8.4 product use cases plus the composing * {@see ProductsController}, then appends four * {@see RouteDefinition} values to the bound {@see RouteRegistrar} * so the §6a `rest_api_init` driver forwards them to WordPress. * * See the file-level docblock for the bootstrap-pipeline ordering * requirement, the Phase-6 minimum behaviors (placeholder search- * provider registry, Clock fallback, route-level permission callback, * synthetic `_http_status` extraction), and the resolution * policy. */ final class ProductsServiceProvider implements ServiceProvider { /** * Registry key under which the §8.1 * {@see SearchProductsUseCase} is bound. */ public const BINDING_SEARCH_PRODUCTS_USE_CASE = 'affilun.rest.search_products_use_case'; /** * Registry key under which the §8.2 * {@see ListProductsUseCase} is bound. */ public const BINDING_LIST_PRODUCTS_USE_CASE = 'affilun.rest.list_products_use_case'; /** * Registry key under which the §8.3 * {@see GetProductUseCase} is bound. */ public const BINDING_GET_PRODUCT_USE_CASE = 'affilun.rest.get_product_use_case'; /** * Registry key under which the §8.4 * {@see SaveProductUseCase} is bound. */ public const BINDING_SAVE_PRODUCT_USE_CASE = 'affilun.rest.save_product_use_case'; /** * Registry key under which the composed * {@see ProductsController} is bound. */ public const BINDING_CONTROLLER = 'affilun.rest.products_controller'; /** * §8.1 search use case override. */ private ?SearchProductsUseCase $searchUseCaseOverride; /** * §8.2 list use case override. */ private ?ListProductsUseCase $listUseCaseOverride; /** * §8.3 read use case override. */ private ?GetProductUseCase $getUseCaseOverride; /** * §8.4 save use case override. */ private ?SaveProductUseCase $saveUseCaseOverride; /** * Composed controller override. */ private ?ProductsController $controllerOverride; /** * Cached {@see ProductsController} bound into the registry. * * Populated during register() so the route-callback closures * can capture the same instance without re-resolving the * registry binding. */ private ?ProductsController $resolvedController = null; /** * Construct a products service provider with optional overrides * for each collaborator. * * Empty / no-arg construction is the production default; tests * pass overrides one axis at a time so the controller graph * can be exercised against synthetic use cases without standing * up the persistence or provider stack. * * @param SearchProductsUseCase|null $searchUseCaseOverride §8.1 use case override. * @param ListProductsUseCase|null $listUseCaseOverride §8.2 use case override. * @param GetProductUseCase|null $getUseCaseOverride §8.3 use case override. * @param SaveProductUseCase|null $saveUseCaseOverride §8.4 use case override. * @param ProductsController|null $controllerOverride Composed controller override. */ public function __construct( ?SearchProductsUseCase $searchUseCaseOverride = null, ?ListProductsUseCase $listUseCaseOverride = null, ?GetProductUseCase $getUseCaseOverride = null, ?SaveProductUseCase $saveUseCaseOverride = null, ?ProductsController $controllerOverride = null ) { $this->searchUseCaseOverride = $searchUseCaseOverride; $this->listUseCaseOverride = $listUseCaseOverride; $this->getUseCaseOverride = $getUseCaseOverride; $this->saveUseCaseOverride = $saveUseCaseOverride; $this->controllerOverride = $controllerOverride; } /** * Bind the four §8.1-§8.4 use cases plus the composed * {@see ProductsController}, then append four * {@see RouteDefinition} values to the bound * {@see RouteRegistrar}. * * Resolution order: * * 1. Resolve {@see ProductRepository} via * {@see PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY}. * Missing binding raises {@see LogicException} so a * misordered bootstrap fails loudly here rather than at * the first REST request. * * 2. Default-construct {@see SystemClock} for the §8.4 save * use case (no Clock binding is published by * {@see PersistenceServiceProvider} in Phase 6). * * 3. Build the four use cases (using the constructor * overrides when supplied, else default-construct against * the resolved repository / clock and the placeholder * search-provider registry described on the file-level * docblock). * * 4. Build the {@see ProductsController} (using the * constructor override when supplied, else default- * construct with fresh mappers / validator / adapter / * capability policy / exception mapper). * * 5. Bind the use cases and the controller under the * `BINDING_*` keys and cache the controller on this * provider so the route-callback closures can capture it. * * 6. Resolve {@see RouteRegistrar} via * {@see RestServiceProvider::BINDING_ROUTE_REGISTRAR}. * Missing binding raises {@see LogicException} for the * same reason as step 1. * * 7. Append four {@see RouteDefinition} values matching * `rest-api-spec.md` §8.1-§8.4 verbatim, each wrapping * the controller method in a thin closure that extracts * and strips the synthetic * {@see ProductsController::META_HTTP_STATUS} key from * the returned envelope and stamps it onto a * {@see \WP_REST_Response} (or the pure-unit-mode array * shim). * * @param ServiceProviderRegistry $registry Registry driving * this bootstrap pass. * * @throws LogicException When the persistence or REST provider * has not registered before this one. */ public function register( ServiceProviderRegistry $registry ): void { $repository = $this->resolveProductRepository( $registry ); $urlRepository = $this->resolveProductUrlRepository( $registry ); $clock = $this->resolveClock(); $keywordExtractor = $this->resolveSearchKeywordsExtractor( $registry ); $searchUseCase = $this->searchUseCaseOverride ?? new SearchProductsUseCase( $this->resolveSearchProviderRegistry( $registry ) ); $listUseCase = $this->listUseCaseOverride ?? new ListProductsUseCase( $repository ); $getUseCase = $this->getUseCaseOverride ?? new GetProductUseCase( $repository ); $saveUseCase = $this->saveUseCaseOverride ?? new SaveProductUseCase( $repository, $clock, $keywordExtractor, $urlRepository ); $controller = $this->controllerOverride ?? new ProductsController( $searchUseCase, $listUseCase, $getUseCase, $saveUseCase, new ProductSummaryMapper(), new ProductDetailMapper(), new SearchResultItemMapper(), new ProviderExceptionMapper(), new CapabilityPolicy(), new RequestValidator(), new RestRequestAdapter() ); $this->resolvedController = $controller; $registry->bind( self::BINDING_SEARCH_PRODUCTS_USE_CASE, $searchUseCase ); $registry->bind( self::BINDING_LIST_PRODUCTS_USE_CASE, $listUseCase ); $registry->bind( self::BINDING_GET_PRODUCT_USE_CASE, $getUseCase ); $registry->bind( self::BINDING_SAVE_PRODUCT_USE_CASE, $saveUseCase ); $registry->bind( self::BINDING_CONTROLLER, $controller ); $registrar = $this->resolveRouteRegistrar( $registry ); $this->appendRoutes( $registrar ); } /** * No-op — routes are registered via * {@see RestServiceProvider}'s `rest_api_init` driver, which * forwards every accumulated {@see RouteDefinition} to * WordPress at the canonical registration moment. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { unset( $hooks ); } /** * Resolve {@see ProductRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductRepository}. */ private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'ProductsServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductRepository ) { $message = sprintf( 'ProductsServiceProvider expected ProductRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve {@see ProductUrlRepository} from the registry when available. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveProductUrlRepository( ServiceProviderRegistry $registry ): ?ProductUrlRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_URL_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductUrlRepository ) { $message = sprintf( 'ProductsServiceProvider expected ProductUrlRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve a {@see Clock} for the §8.4 save use case. * * No Clock binding is currently published by * {@see PersistenceServiceProvider} (the §6.4 sync clock is a * §9 concern), so the Phase-6 minimum default-constructs a * {@see SystemClock}. A future slice that publishes a Clock * binding can wire it through the existing * `$saveUseCaseOverride` constructor parameter. */ private function resolveClock(): Clock { return new SystemClock(); } /** * Resolve the product search-keyword extractor. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveSearchKeywordsExtractor( ServiceProviderRegistry $registry ): FallbackProductSearchKeywordsExtractor|CloudProductSearchKeywordsExtractor { $fallback = new FallbackProductSearchKeywordsExtractor(); $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { return $fallback; } $store = $registry->get( $key ); if ( ! $store instanceof OptionStore ) { return $fallback; } return new CloudProductSearchKeywordsExtractor( new OptionStoreSettingsRepository( $store ), $fallback ); } /** * Resolve {@see RouteRegistrar} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see RouteRegistrar}. */ private function resolveRouteRegistrar( ServiceProviderRegistry $registry ): RouteRegistrar { $key = RestServiceProvider::BINDING_ROUTE_REGISTRAR; if ( ! $registry->has( $key ) ) { $message = 'ProductsServiceProvider requires RestServiceProvider to register before it' . ' (missing RouteRegistrar binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $registrar = $registry->get( $key ); if ( ! $registrar instanceof RouteRegistrar ) { $message = sprintf( 'ProductsServiceProvider expected RouteRegistrar under "%s"; got %s.', $key, is_object( $registrar ) ? get_class( $registrar ) : gettype( $registrar ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $registrar; } /** * Resolve the search-provider registry handed to * {@see SearchProductsUseCase}. * * Strict-review fix #3 (KI-013): previously this provider always * wired the use case with a single anonymous-class placeholder * implementing {@see SearchProviderInterface} as a §8.1 * "registry must be non-empty" satisfier, with the comment * "Phase 7+ slices rebind the use case with a real provider * list" — but no such slice ever landed. As a result every * `GET /affilun/v1/products/search?provider=amazon|rakuten|yahoo_shopping` * call dropped through to the use case's "unknown provider * token" branch even when the §13.4 Settings tab carried * fully-configured credentials and {@see ProviderServiceProvider} * had bound the corresponding {@see ProviderInterface} into * {@see ProviderServiceProvider::BINDING_PROVIDER_REGISTRY}. * * The fix soft-resolves the production * {@see ProviderRegistry} from the registry and projects every * registered provider that implements {@see SearchProviderInterface} * into the `key => provider` map the use case requires. When the * binding is missing (pure-unit harness, partial bootstrap, * stripped-down test wiring) the method falls back to the * historical placeholder so the use case constructor's * "non-empty registry" precondition still holds. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @return array */ private function resolveSearchProviderRegistry( ServiceProviderRegistry $registry ): array { $key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( ! $registry->has( $key ) ) { return $this->buildPlaceholderProviderRegistry(); } $bound = $registry->get( $key ); if ( ! $bound instanceof ProviderRegistry ) { return $this->buildPlaceholderProviderRegistry(); } $searchProviders = array(); foreach ( $bound->all() as $providerKey => $provider ) { if ( $provider instanceof SearchProviderInterface ) { $searchProviders[ $providerKey ] = $provider; } } if ( $searchProviders === array() ) { // No provider in the production registry advertises a // SearchProviderInterface (typical when no §13.4 // credentials have been saved yet on a fresh install). // Fall back to the placeholder so the §8.1 use case // constructor's "registry must be non-empty" // precondition still holds and the controller can // surface the consumer-friendly "unknown provider // token" error per route call rather than crashing // at bootstrap time. return $this->buildPlaceholderProviderRegistry(); } return $searchProviders; } /** * Build the Phase-6 minimum placeholder search-provider * registry used as a fallback when no production * {@see SearchProviderInterface} is registered. * * Originally this was the only registry the §8.1 dispatch path * ever saw (see the strict-review note on * {@see self::resolveSearchProviderRegistry()} for why that * was a defect). It now functions purely as the "registry * must be non-empty" satisfier for pure-unit harnesses and * for fresh installs that have not yet saved any §13.4 * provider credentials. * * @return array */ private function buildPlaceholderProviderRegistry(): array { $placeholder = new class() implements SearchProviderInterface { /** * Stable §4 provider key for the placeholder stub. */ public function key(): string { return 'placeholder'; } /** * Human-readable label for the placeholder stub. */ public function label(): string { return 'Placeholder'; } /** * Empty capabilities snapshot — the placeholder * supports no §8 features. */ public function capabilities(): ProviderCapabilities { return ProviderCapabilities::none(); } /** * `STATUS_UNCONFIGURED` health probe — the placeholder * has no upstream API to probe. */ public function healthCheck(): ProviderHealthStatus { return ProviderHealthStatus::unconfigured( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ); } /** * Placeholder supports no §8 feature. * * @param string $feature §8 feature key. */ public function supportsFeature( string $feature ): bool { unset( $feature ); return false; } /** * Pass through the snapshot's detail URL verbatim — the * placeholder mints no affiliate URLs. * * @param ProductSnapshot $snapshot Snapshot to track. * @param AffiliateContext $context Per-request context. */ public function buildAffiliateLink( ProductSnapshot $snapshot, AffiliateContext $context ): AffiliateLinkResult { unset( $context ); return AffiliateLinkResult::passthrough( $snapshot->detailUrl() ); } /** * Return an empty {@see SearchResult} honoring the * caller's pagination context. * * @param SearchQuery $query Normalized search query. */ public function searchProducts( SearchQuery $query ): SearchResult { return SearchResult::empty( $this->key(), $query->page(), $query->perPage() ); } }; return array( $placeholder->key() => $placeholder ); } /** * Append the four §8.1-§8.4 {@see RouteDefinition} values to * the supplied registrar. * * Each definition wraps the corresponding controller method in * a thin {@see Closure} built by * {@see self::buildRouteCallback()} that extracts and strips * the synthetic {@see ProductsController::META_HTTP_STATUS} * key from the returned envelope. The §8.1 partial-success * contract and the §8.4 insert-vs-update HTTP-status branch * both flow through the same wrapper because the controller * stamps the status onto the envelope itself. * * @param RouteRegistrar $registrar Bound registrar. */ private function appendRoutes( RouteRegistrar $registrar ): void { $permissionCallback = static function ( $request = null ): bool { return $request !== false; }; $registrar->add( new RouteDefinition( RestNamespace::FULL, '/products/search', RouteDefinition::METHOD_GET, ProductsController::ROUTE_TOKEN_SEARCH, array(), $this->buildRouteCallback( 'search' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/products', RouteDefinition::METHOD_GET, ProductsController::ROUTE_TOKEN_LIST, array(), $this->buildRouteCallback( 'list' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/products/(?P\d+)', RouteDefinition::METHOD_GET, ProductsController::ROUTE_TOKEN_READ, array(), $this->buildRouteCallback( 'read' ), $permissionCallback ) ); $registrar->add( new RouteDefinition( RestNamespace::FULL, '/products', RouteDefinition::METHOD_POST, ProductsController::ROUTE_TOKEN_WRITE, array(), $this->buildRouteCallback( 'write' ), $permissionCallback ) ); } /** * Build a route-callback {@see Closure} that dispatches to the * named controller method and adapts the returned envelope * onto a {@see \WP_REST_Response} (or the pure-unit-mode * array shim). * * The closure captures `$this` so the cached * {@see self::$resolvedController} is read at invocation time; * the four method names are resolved through a `match` * expression rather than dynamic-dispatch (`->{$method}()`) * so PHPStan can verify each call site against the controller's * static type. The match's `default` arm raises * {@see LogicException} as a defensive guard against a future * caller passing an unknown method name. * * Synthetic key extraction: * * - The controller returns * `Envelope::toArray() + array(META_HTTP_STATUS => int)`. * * - The closure reads the int (defaulting to 200 when * absent), strips the key from the array, and passes the * stripped envelope as the response body. * * - When `WP_REST_Response` is loadable (production path), * the closure returns `new \WP_REST_Response($body, $status)`. * Otherwise (pure-unit harness), it returns the * `array('data' => $body, 'status' => $status)` shim. * * @param string $method One of `'search'`, `'list'`, `'read'`, `'write'`. * * @phpstan-return Closure(mixed): mixed */ private function buildRouteCallback( string $method ): Closure { return function ( mixed $request ) use ( $method ): mixed { $controller = $this->resolvedController; if ( $controller === null ) { $message = 'ProductsServiceProvider route callback invoked before register() ran.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $envelope = match ( $method ) { 'search' => $controller->search( $request ), 'list' => $controller->list( $request ), 'read' => $controller->read( $request ), 'write' => $controller->write( $request ), default => $this->raiseUnknownMethod( $method ), }; $status = isset( $envelope[ ProductsController::META_HTTP_STATUS ] ) && is_int( $envelope[ ProductsController::META_HTTP_STATUS ] ) ? $envelope[ ProductsController::META_HTTP_STATUS ] : 200; unset( $envelope[ ProductsController::META_HTTP_STATUS ] ); if ( class_exists( '\\WP_REST_Response' ) ) { return new \WP_REST_Response( $envelope, $status ); } return array( 'data' => $envelope, 'status' => $status, ); }; } /** * Raise a {@see LogicException} for an unknown route-callback * method name. * * Extracted as a helper so the {@see self::buildRouteCallback()} * `match` expression's `default` arm can call it as an * expression (the `match` arm grammar requires an expression, * not a statement). * * @param string $method Offending method name. * * @return never * * @throws LogicException Always. */ private function raiseUnknownMethod( string $method ): array { $message = sprintf( 'ProductsServiceProvider route callback received unknown method "%s".', $method ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } }