` — the same shape * {@see \Affilun\Domain\Search\SearchQuery::filters} * uses — so the rendering / mapping layers do not have to * special-case affiliate parameters separately from search * filters. Empty map by default. * * Per §7.7, "provider ごとに必要な項目のみ使用する" / "不要項目は * 無視してよい": every named field is nullable on this DTO because * the cross-provider field set is a union of what every supported * provider might need; a given provider implementation reads only * the fields that apply to it, ignores the rest, and is itself * responsible for raising * {@see \Affilun\Domain\Provider\Exception\ProviderConfigurationException} * (Phase 3 slice 3f) when a required field is missing for that * provider's request shape. The DTO does not encode per-provider * required-field rules because doing so would re-couple the type * to the closed set of providers — exactly what §2.4 (capabilities) * is meant to avoid. * * Instances are immutable. There are no `with*()` mutation helpers * because an affiliate context is composed once at the boundary * (REST controller / render pipeline) and then handed off to the * provider implementation; in-place edits would only invite * ambiguity about which fields are authoritative. * * @package Affilun\Domain\Affiliate */ declare(strict_types=1); namespace Affilun\Domain\Affiliate; use InvalidArgumentException; /** * Immutable normalized affiliate-context input. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §7.7, the per-provider field-use * convention, and the rationale for keeping the type free of * mutation helpers. */ final class AffiliateContext { /** * Cross-provider canonical tracking identifier, or null. */ private ?string $trackingId; /** * Amazon Associates Program tag, or null. */ private ?string $associateTag; /** * 楽天 / Yahoo!ショッピング affiliate account id, or null. */ private ?string $affiliateId; /** * Secondary tracking identifier (Yahoo!ショッピング `sid`), * or null. */ private ?string $sid; /** * Program / placement identifier, or null. */ private ?string $pid; /** * Application-layer campaign tag, or null. */ private ?string $campaign; /** * Sub-identifier for finer-grained attribution, or null. */ private ?string $subId; /** * UTM-style source tag, or null. */ private ?string $source; /** * UTM-style medium tag, or null. */ private ?string $medium; /** * Structured catch-all for provider-specific parameters. * * @var array */ private array $customParameters; /** * Construct an affiliate context with explicit values for * every field. * * Every named field is nullable per §7.7; the constructor * trims surrounding whitespace from non-null strings and * rejects them when the trim leaves an empty string — * "present but empty" is treated as a programming error * because it conveys nothing the null case does not already * convey. * * `customParameters` keys MUST be non-empty strings (after * trimming) and values MUST be scalar or null. Nested arrays / * objects are rejected because §7.7 expects a flat parameter * surface that providers can map to URL query parameters. * * @param string|null $trackingId Canonical tracking id. * @param string|null $associateTag Amazon Associates tag. * @param string|null $affiliateId Rakuten / Yahoo affiliate id. * @param string|null $sid Secondary tracking id. * @param string|null $pid Program / placement id. * @param string|null $campaign Campaign tag. * @param string|null $subId Sub-identifier. * @param string|null $source UTM-style source tag. * @param string|null $medium UTM-style medium tag. * @param array $customParameters Structured catch-all map. * * @throws InvalidArgumentException When any named field is the * empty string after trimming, * or when `customParameters` * carries a non-string key or * a non-scalar value. */ public function __construct( ?string $trackingId, ?string $associateTag, ?string $affiliateId, ?string $sid, ?string $pid, ?string $campaign, ?string $subId, ?string $source, ?string $medium, array $customParameters ) { $this->trackingId = self::normalizeOptionalString( 'trackingId', $trackingId ); $this->associateTag = self::normalizeOptionalString( 'associateTag', $associateTag ); $this->affiliateId = self::normalizeOptionalString( 'affiliateId', $affiliateId ); $this->sid = self::normalizeOptionalString( 'sid', $sid ); $this->pid = self::normalizeOptionalString( 'pid', $pid ); $this->campaign = self::normalizeOptionalString( 'campaign', $campaign ); $this->subId = self::normalizeOptionalString( 'subId', $subId ); $this->source = self::normalizeOptionalString( 'source', $source ); $this->medium = self::normalizeOptionalString( 'medium', $medium ); $this->customParameters = self::normalizeCustomParameters( $customParameters ); } /** * Construct an empty affiliate context — every named field * null and `customParameters` empty. * * Convenience factory for the common "no affiliate tracking * configured" case so call sites do not have to spell out ten * `null` arguments. Useful for tests and for rendering paths * that want to preview an unaffiliated detail link. */ public static function empty(): self { return new self( null, null, null, null, null, null, null, null, null, array() ); } /** * Cross-provider canonical tracking identifier, or null. */ public function trackingId(): ?string { return $this->trackingId; } /** * Amazon Associates Program tag, or null. */ public function associateTag(): ?string { return $this->associateTag; } /** * 楽天 / Yahoo!ショッピング affiliate account id, or null. */ public function affiliateId(): ?string { return $this->affiliateId; } /** * Secondary tracking identifier, or null. */ public function sid(): ?string { return $this->sid; } /** * Program / placement identifier, or null. */ public function pid(): ?string { return $this->pid; } /** * Application-layer campaign tag, or null. */ public function campaign(): ?string { return $this->campaign; } /** * Sub-identifier for finer-grained attribution, or null. */ public function subId(): ?string { return $this->subId; } /** * UTM-style source tag, or null. */ public function source(): ?string { return $this->source; } /** * UTM-style medium tag, or null. */ public function medium(): ?string { return $this->medium; } /** * Structured catch-all for provider-specific parameters. * * @return array */ public function customParameters(): array { return $this->customParameters; } /** * Report whether this context carries any non-null information. * * Convenience guard so call sites can branch on "is there * anything to apply?" without manually inspecting every * accessor. Returns true when every named field is null AND * `customParameters` is empty; the inverse, false, means at * least one tracking signal is present. */ public function isEmpty(): bool { return $this->trackingId === null && $this->associateTag === null && $this->affiliateId === null && $this->sid === null && $this->pid === null && $this->campaign === null && $this->subId === null && $this->source === null && $this->medium === null && $this->customParameters === array(); } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for every named field on this DTO because §7.7 treats * each field as either "present with content" or "absent" — * "present but empty" conveys nothing additional and is * therefore a programming error. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @return string|null Trimmed value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'AffiliateContext %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `customParameters` map. * * Keys MUST be non-empty strings (after trimming) and values * MUST be scalar or null. Nested arrays / objects are rejected * because §7.7 expects a flat parameter surface that providers * can map to URL query parameters. * * @param array $customParameters Raw map. * * @return array * * @throws InvalidArgumentException When any key is not a * non-empty string or any * value is not scalar / null. */ private static function normalizeCustomParameters( array $customParameters ): array { $normalized = array(); foreach ( $customParameters as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'AffiliateContext customParameters 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 ); } $trimmedKey = trim( $key ); if ( $trimmedKey === '' ) { $message = 'AffiliateContext customParameters 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 !== null && ! is_scalar( $value ) ) { $message = sprintf( 'AffiliateContext customParameters["%s"] must be scalar or null; got %s.', $trimmedKey, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmedKey ] = $value; } return $normalized; } }