` keyed by the §4 * stable provider token. The UseCase honors the array's * iteration order verbatim so the bundle's `results` list * reflects the order the controller / DI container chose; * the REST mapper relies on this ordering to render the §8.1 * example's "Amazon → Yahoo → Rakuten" sequence. * * - The registry MUST carry at least one entry. An empty * registry is a programming error at boot time (the DI * container forgot to wire any provider) and surfaces as * {@see \LogicException}; the caller cannot meaningfully * recover from this state at runtime. * * - The constructor validates every key/value pair so a * misconfigured DI binding fails loudly at construction * rather than at the first dispatch. * * - Non-{@see ProviderException} throwables (programming errors, * {@see \TypeError}, {@see \LogicException}) are NOT captured * into the warnings map even in `all` mode: they propagate so * the failure surfaces verbatim rather than being silently * downgraded to a partial-success envelope. * * @package Affilun\Application\Product\UseCase */ declare(strict_types=1); namespace Affilun\Application\Product\UseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Domain\Provider\SearchProviderInterface; use Affilun\Domain\Search\SearchQuery; use Affilun\Domain\Search\SearchResult; use InvalidArgumentException; use LogicException; /** * Phase 6 UseCase that drives the §8.1 * `GET /affilun/v1/products/search` endpoint. * * See the file-level docblock for the §8.1 partial-success * contract that binds the `provider=all` dispatch path, the * single-provider exception-propagation contract, and the * registration-order invariant on the returned bundle's results. */ final class SearchProductsUseCase { /** * Sentinel provider token requesting cross-provider dispatch. * * Matches the §8.1 `provider=all` query parameter value * verbatim so the REST controller can forward the value * through without remapping. */ public const PROVIDER_TOKEN_ALL = 'all'; /** * Registered providers keyed by their §4 stable token. * * Iteration order is the registration order; the UseCase * honors it verbatim so callers can rely on a deterministic * ordering when rendering the §8.1 envelope. * * @var array */ private array $providersByKey; /** * Construct the UseCase with an explicit provider registry. * * Validation: * * - Empty registry → {@see LogicException}; a search UseCase * wired without any providers cannot answer any request. * * - Every key MUST be a non-empty string after trimming. * Non-string keys, empty-string keys, and whitespace-only * keys raise {@see InvalidArgumentException}. * * - Every value MUST implement * {@see SearchProviderInterface}. Other types raise * {@see InvalidArgumentException}. * * @param array $providersByKey Provider registry. * * @throws LogicException When the registry is empty. * @throws InvalidArgumentException When any registry entry * violates the validation * rules summarized above. */ public function __construct( array $providersByKey ) { if ( $providersByKey === array() ) { $message = 'SearchProductsUseCase requires at least one registered provider.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $normalized = array(); foreach ( $providersByKey as $key => $provider ) { if ( ! is_string( $key ) ) { $message = sprintf( 'SearchProductsUseCase: provider registry keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( trim( $key ) === '' ) { $message = 'SearchProductsUseCase: provider registry keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $provider instanceof SearchProviderInterface ) { $message = sprintf( 'SearchProductsUseCase: provider registry["%s"] must implement SearchProviderInterface; got %s.', $key, is_object( $provider ) ? get_class( $provider ) : gettype( $provider ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $key ] = $provider; } $this->providersByKey = $normalized; } /** * Execute the §8.1 search dispatch. * * See the file-level docblock for the dispatch decision tree * (single-provider propagation vs. cross-provider partial- * success capture) and the §8.1 `meta.warnings` mapping the * returned bundle's `providerWarnings` map drives. * * @param SearchQuery $query Normalized search query. * @param string $providerToken §4 provider token, or * {@see self::PROVIDER_TOKEN_ALL}. * * @return SearchResultBundle Aggregated results plus captured * warnings. * * @throws InvalidArgumentException When `$providerToken` is * empty after trimming, when * it is not a registered * provider key (and not * {@see self::PROVIDER_TOKEN_ALL}), * or — single-provider mode * only — any §10.2 * ProviderException raised by * the addressed provider * propagates verbatim. */ public function execute( SearchQuery $query, string $providerToken ): SearchResultBundle { $normalizedToken = trim( $providerToken ); if ( $normalizedToken === '' ) { $message = 'SearchProductsUseCase: providerToken must be non-empty.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $normalizedToken === self::PROVIDER_TOKEN_ALL ) { return $this->dispatchAll( $query ); } if ( ! array_key_exists( $normalizedToken, $this->providersByKey ) ) { $message = sprintf( 'SearchProductsUseCase: unknown provider token "%s".', $normalizedToken ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result = $this->providersByKey[ $normalizedToken ]->searchProducts( $query ); return new SearchResultBundle( array( $result ), array() ); } /** * Cross-provider dispatch path. * * Invokes every registered provider in registration order, * capturing {@see ProviderException} subclasses into the * warnings map and appending successful results to the * bundle's results list. Non-{@see ProviderException} * throwables propagate verbatim per the file-level docblock. * * @param SearchQuery $query Normalized search query. */ private function dispatchAll( SearchQuery $query ): SearchResultBundle { /** @var list $results */ $results = array(); /** @var array $warnings */ $warnings = array(); foreach ( $this->providersByKey as $token => $provider ) { try { $results[] = $provider->searchProducts( $query ); } catch ( ProviderException $exception ) { $warnings[ $token ] = $exception; } } return new SearchResultBundle( $results, $warnings ); } }