` of providers that * answered the dispatch successfully, in registration order. * Each entry's {@see SearchResult::provider()} identifies * which provider produced the slice; the §8.1 partial-success * example surfaces them merged into a flat `items` array, but * this VO preserves the per-provider grouping so the REST * mapper can decide whether to merge or render side-by-side. * * - `providerWarnings` is an `array` * keyed by the §4 provider token (`amazon`, `rakuten`, * `yahoo`). Each entry records the captured exception so the * REST mapper can surface the §10.2 error code, the §10.3 * human-readable label, and the §10 structured context fields * onto `meta.warnings[]`. Successful providers are NOT * represented in this map; absence of a token from the map * means "this provider succeeded". * * Design notes: * * - The two parallel surfaces (`results` and `providerWarnings`) * are deliberately not collapsed into a single * "per-provider outcome" type because the §8.1 response shape * emits them as distinct envelopes (`data.items` vs. * `meta.warnings`). Keeping them separate at the Application * layer matches the wire shape one-for-one. * * - Single-provider dispatch (`provider=amazon`, etc.) does NOT * populate `providerWarnings`. In single-provider mode a * provider exception propagates up to the REST controller * verbatim and surfaces as the request-level error envelope, * so any bundle the controller observes from a single-provider * dispatch carries an empty warnings map. * * - The constructor validates that every warning key is a * non-empty string and every value is a * {@see ProviderException}; the exception's own * {@see ProviderException::provider()} is NOT cross-checked * against the map key because the dispatcher may key the map * by the requested token (which can differ from the raised * provider's canonical key in misconfigured registries) and * the REST mapper relies on the requested-token form. * * @package Affilun\Application\Product\UseCase */ declare(strict_types=1); namespace Affilun\Application\Product\UseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Domain\Search\SearchResult; use InvalidArgumentException; /** * Immutable multi-provider search aggregation envelope. * * See the file-level docblock for the §8.1 `meta.warnings` * mapping, the registration-order invariant on `results`, and the * rationale for keeping single-provider dispatch's exception * propagation outside this VO. */ final class SearchResultBundle { /** * Successful per-provider results in registration order. * * @var list */ private array $results; /** * Captured provider exceptions keyed by provider token. * * @var array */ private array $providerWarnings; /** * Construct a bundle with explicit values for every field. * * Validation: * * - `results` MUST be a `list<>` (0-indexed, dense) of * {@see SearchResult} instances. Non-{@see SearchResult} * entries and non-list keys raise * {@see InvalidArgumentException} carrying the offending * index for diagnosis. * * - `providerWarnings` keys MUST be non-empty strings; * values MUST be {@see ProviderException} instances. * Non-string keys, empty-string keys, and non-exception * values raise {@see InvalidArgumentException}. * * @param list $results Successful results. * @param array $providerWarnings Captured exceptions. * * @throws InvalidArgumentException When any field violates the * cross-field validation rules * summarized above. */ public function __construct( array $results, array $providerWarnings ) { $expectedIndex = 0; foreach ( $results as $key => $result ) { if ( $key !== $expectedIndex ) { $message = 'SearchResultBundle: results must be a 0-indexed list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $result instanceof SearchResult ) { $message = sprintf( 'SearchResultBundle: results[%d] must be a SearchResult; got %s.', $expectedIndex, is_object( $result ) ? get_class( $result ) : gettype( $result ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } ++$expectedIndex; } $normalizedWarnings = array(); foreach ( $providerWarnings as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'SearchResultBundle: providerWarnings 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 = 'SearchResultBundle: providerWarnings keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof ProviderException ) { $message = sprintf( 'SearchResultBundle: providerWarnings[%s] must be a ProviderException; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalizedWarnings[ $key ] = $value; } /** @var list $results */ $this->results = $results; $this->providerWarnings = $normalizedWarnings; } /** * Successful per-provider results in registration order. * * @return list */ public function results(): array { return $this->results; } /** * Captured provider exceptions keyed by provider token. * * @return array */ public function providerWarnings(): array { return $this->providerWarnings; } /** * Whether this bundle carries any captured provider warnings. */ public function hasWarnings(): bool { return $this->providerWarnings !== array(); } /** * Total number of items aggregated across every successful * result. * * Equivalent to `array_sum(array_map(fn($r) => $r->count(), * $results))`; exposed as a dedicated helper so REST mappers * can render the §8.1 partial-success envelope without * recomputing the sum from the items list. */ public function totalItems(): int { $sum = 0; foreach ( $this->results as $result ) { $sum += $result->count(); } return $sum; } /** * Equality against another bundle. * * Results and warnings are both compared key-by-key on * instance identity ({@see SearchResult} and * {@see ProviderException} are reference types and the * Domain layer does not expose value-equal `equals()` helpers * for either; a bundle's "same shape, different instance" * peer is uncommon enough in practice that callers can build * their own deep comparison when they actually need one). * * @param self $other Bundle to compare. */ public function equals( self $other ): bool { if ( count( $this->results ) !== count( $other->results ) ) { return false; } foreach ( $this->results as $index => $result ) { if ( $result !== $other->results[ $index ] ) { return false; } } if ( count( $this->providerWarnings ) !== count( $other->providerWarnings ) ) { return false; } foreach ( $this->providerWarnings as $key => $exception ) { if ( ! array_key_exists( $key, $other->providerWarnings ) ) { return false; } if ( $exception !== $other->providerWarnings[ $key ] ) { return false; } } return true; } }