` * keyed by header name. Names are normalized to a canonical * form (trimmed, with surrounding whitespace removed) so * header lookups stay consistent regardless of the casing the * caller used. The DTO does NOT lowercase header names because * some upstreams (notably 楽天 / Yahoo) inspect their own * header echoes verbatim; preserving the supplied casing keeps * fixture-based testing trivial. Duplicate header names are * not supported at this layer — multi-value headers are * uncommon outside of `Set-Cookie` (a response-only concern) * and the existing provider contracts do not require them. * * - Query parameters are held as an associative * `array` keyed by parameter name. Values * are NOT URL-encoded at construction time — encoding happens * when the request is rendered into a URL by * {@see self::url()} consumers (typically the HTTP client * adapter). Holding raw values keeps the DTO comparable in * tests without fighting `http_build_query()` ordering. * * - The body is held as a nullable string. Provider * implementations that need to send JSON or form-encoded * payloads serialize before constructing the request; this * layer does not parse, mutate, or re-encode the body. * * - The HTTP method is held as an uppercase string and validated * against a closed set of canonical methods. The set is * deliberately narrow (GET / POST / PUT / PATCH / DELETE / * HEAD) because the provider contracts in Phase 4-12 do not * need broader coverage; future provider work can extend the * set with a deliberate code change. * * Instances are immutable. Two narrow `with*()` mutation helpers * are provided because they map to frequent provider-implementation * flows: * * - {@see self::withHeader()} adds or replaces a single header * without re-spelling the rest of the headers map. Used by * adapters that need to stamp `User-Agent` or signing headers * on top of a request the provider built. * * - {@see self::withQueryParameter()} adds or replaces a single * query parameter. Used by adapters that need to attach * credentials (`applicationId`, `affiliateId`, ...) on top of * a request the provider built. * * Other fields (method, URL base, body) are not mutable at this * layer — changing them would constitute a different request. * * @package Affilun\Domain\Http */ declare(strict_types=1); namespace Affilun\Domain\Http; use InvalidArgumentException; /** * Immutable description of a single outbound HTTP request. * * See the file-level docblock for the driver-agnostic field design, * the per-field validation rules, and the rationale for the two * `with*()` mutation helpers exposed here. */ final class HttpRequest { /** * Canonical HTTP GET method token. * * Exposed as a class constant so call sites can reference the * exact token without duplicating the literal, and so a future * spec change has a single location to update. */ public const METHOD_GET = 'GET'; /** * Canonical HTTP POST method token. */ public const METHOD_POST = 'POST'; /** * Canonical HTTP PUT method token. */ public const METHOD_PUT = 'PUT'; /** * Canonical HTTP PATCH method token. */ public const METHOD_PATCH = 'PATCH'; /** * Canonical HTTP DELETE method token. */ public const METHOD_DELETE = 'DELETE'; /** * Canonical HTTP HEAD method token. */ public const METHOD_HEAD = 'HEAD'; /** * Closed set of accepted HTTP method tokens, in declaration * order. Maintained as a class-level constant so the validator * and the test suite can iterate the same source of truth. * * @var list */ private const VALID_METHODS = array( self::METHOD_GET, self::METHOD_POST, self::METHOD_PUT, self::METHOD_PATCH, self::METHOD_DELETE, self::METHOD_HEAD, ); /** * HTTP method, in uppercase, drawn from {@see self::VALID_METHODS}. */ private string $method; /** * Target URL. Validated as a non-empty `http(s)://` URL at * construction time, matching the same scheme guard the * Domain-layer Product / Affiliate URL fields apply. */ private string $url; /** * Header map keyed by header name (verbatim casing preserved). * * @var array */ private array $headers; /** * Query parameter map keyed by parameter name. Values are held * as scalars or null; URL encoding happens when the request is * rendered into a URL by the consumer. * * @var array */ private array $queryParameters; /** * Optional request body, or null when the request has no body * (typical for GET / HEAD). */ private ?string $body; /** * Per-call timeout in seconds, or null to defer to the * adapter's default. Held as a positive integer so the field * round-trips cleanly through `wp_remote_*` and PSR-18 * implementations alike (both expect integer seconds, not * floats). */ private ?int $timeoutSeconds; /** * Construct an HTTP request with explicit values for every * field. * * The constructor is exhaustive so a programming error * (forgetting to declare a header on a write, swapping URL / * body) surfaces at the type system boundary rather than at the * HTTP client adapter. * * Validation summary: * * - `method` is uppercased and matched against * {@see self::VALID_METHODS}; any other token raises * {@see InvalidArgumentException} carrying the offending * value verbatim and a comma-separated list of the * accepted methods for diagnosis. * - `url` is trimmed and validated as a `http(s)://...` URL. * - `headers` keys are trimmed and rejected when empty; * values are trimmed and rejected when empty (use the * null-style "no header" by simply omitting the entry). * - `queryParameters` keys are trimmed and rejected when * empty; values must be scalar or null. * - `body` is preserved verbatim including empty strings — * an empty body string is meaningful for some providers * and MUST NOT be coerced to null. * - `timeoutSeconds`, when non-null, must be `>= 1`. * * @param string $method HTTP method. * @param string $url Target URL. * @param array $headers Header map. * @param array $queryParameters Query param map. * @param string|null $body Optional request body. * @param int|null $timeoutSeconds Per-call timeout in seconds. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $method, string $url, array $headers = array(), array $queryParameters = array(), ?string $body = null, ?int $timeoutSeconds = null ) { $this->method = self::normalizeMethod( $method ); $this->url = self::normalizeUrl( $url ); $this->headers = self::normalizeHeaders( $headers ); $this->queryParameters = self::normalizeQueryParameters( $queryParameters ); $this->body = $body; $this->timeoutSeconds = self::normalizeTimeout( $timeoutSeconds ); } /** * Construct a GET request — the common case for provider * search and lookup calls. * * Convenience factory so call sites do not have to spell out * {@see self::METHOD_GET} every time. The remaining arguments * follow the primary constructor's contract verbatim. * * @param string $url Target URL. * @param array $queryParameters Query param map. * @param array $headers Header map. * @param int|null $timeoutSeconds Per-call timeout. */ public static function get( string $url, array $queryParameters = array(), array $headers = array(), ?int $timeoutSeconds = null ): self { return new self( self::METHOD_GET, $url, $headers, $queryParameters, null, $timeoutSeconds ); } /** * Closed set of accepted HTTP method tokens, in declaration * order. * * Exposed for tests and for diagnostics surfaces that want to * enumerate the valid tokens without duplicating the list. * * @return list */ public static function validMethods(): array { return self::VALID_METHODS; } /** * HTTP method in uppercase. */ public function method(): string { return $this->method; } /** * Target URL. */ public function url(): string { return $this->url; } /** * Header map keyed by header name (verbatim casing preserved). * * @return array */ public function headers(): array { return $this->headers; } /** * Look up a single header by name. * * Lookup is case-insensitive on the supplied name so call sites * can ask for `Authorization` regardless of the casing the * provider used when constructing the request. Returns null * when no matching header is present. * * @param string $name Header name to look up. */ public function header( string $name ): ?string { $needle = strtolower( trim( $name ) ); if ( $needle === '' ) { return null; } foreach ( $this->headers as $key => $value ) { if ( strtolower( $key ) === $needle ) { return $value; } } return null; } /** * Query parameter map. * * @return array */ public function queryParameters(): array { return $this->queryParameters; } /** * Optional request body, or null. */ public function body(): ?string { return $this->body; } /** * Per-call timeout in seconds, or null to defer to the * adapter's default. */ public function timeoutSeconds(): ?int { return $this->timeoutSeconds; } /** * Produce a copy of this request with a single header added or * replaced. * * Used by adapters that need to stamp a header (`User-Agent`, * signing headers, `Accept`) on top of a request the provider * built. Header lookup for replacement is case-insensitive so a * caller's `Authorization` overrides a provider's `authorization` * regardless of casing; the supplied casing is preserved on * storage. * * @param string $name Header name. * @param string $value Header value. * * @throws InvalidArgumentException When `$name` is empty after * trimming or `$value` is the * empty string after trimming. */ public function withHeader( string $name, string $value ): self { $normalizedName = trim( $name ); $normalizedValue = trim( $value ); if ( $normalizedName === '' ) { $message = 'HttpRequest::withHeader() requires a non-empty header name.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $normalizedValue === '' ) { $message = sprintf( 'HttpRequest::withHeader() requires a non-empty value for header "%s".', $normalizedName ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $needle = strtolower( $normalizedName ); $updated = array(); foreach ( $clone->headers as $key => $existing ) { if ( strtolower( $key ) !== $needle ) { $updated[ $key ] = $existing; } } $updated[ $normalizedName ] = $normalizedValue; $clone->headers = $updated; return $clone; } /** * Produce a copy of this request with a single query parameter * added or replaced. * * Used by adapters that need to attach credentials * (`applicationId`, `affiliateId`, ...) on top of a request the * provider built. Replacement is case-sensitive on the parameter * name because URL query parameters are case-sensitive on the * wire and a "case-insensitive replace" would silently shadow a * legitimately-distinct parameter. * * @param string $name Parameter name. * @param scalar|null $value Parameter value. * * @throws InvalidArgumentException When `$name` is empty after * trimming or `$value` is not * scalar / null. */ public function withQueryParameter( string $name, $value ): self { $normalizedName = trim( $name ); if ( $normalizedName === '' ) { $message = 'HttpRequest::withQueryParameter() requires a non-empty parameter name.'; // 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( 'HttpRequest::withQueryParameter() value for "%s" must be scalar or null; got %s.', $normalizedName, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->queryParameters[ $normalizedName ] = $value; return $clone; } /** * Validate and normalize the HTTP method at construction. * * The method is trimmed and uppercased so a carelessly-decoded * payload (`get`, ` post `) is normalized to the same * canonical form a freshly-constructed method would carry. The * result MUST appear in {@see self::VALID_METHODS}. * * @param string $method Raw method as supplied by the caller. * * @throws InvalidArgumentException When `$method` is not a * member of the closed set. */ private static function normalizeMethod( string $method ): string { $trimmed = strtoupper( trim( $method ) ); if ( ! in_array( $trimmed, self::VALID_METHODS, true ) ) { $message = sprintf( 'HttpRequest method must be one of [%s]; got "%s".', implode( ', ', self::VALID_METHODS ), $method ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the target URL at construction. * * Trims surrounding whitespace, rejects empty / non-`http(s)://` * URLs using the same scheme guard the Domain-layer Product / * Affiliate URL fields apply. * * @param string $url Raw URL. * * @throws InvalidArgumentException When `$url` is empty after * trimming, fails URL * validation, or uses a * non-http scheme. */ private static function normalizeUrl( string $url ): string { $trimmed = trim( $url ); if ( $trimmed === '' ) { $message = 'HttpRequest url must be a non-empty URL.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) { $message = sprintf( 'HttpRequest url must be a valid http(s):// URL; got "%s".', $url ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency. $scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) ); if ( $scheme !== 'http' && $scheme !== 'https' ) { $message = sprintf( 'HttpRequest url must use the http or https scheme; got "%s".', $url ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the headers map. * * Keys are trimmed (preserving casing) and rejected when empty; * values are trimmed and rejected when empty. Non-string entries * raise {@see InvalidArgumentException}. * * @param array $headers Raw headers map. * * @return array * * @throws InvalidArgumentException When any entry violates the * per-entry rules. */ private static function normalizeHeaders( array $headers ): array { $normalized = array(); foreach ( $headers as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'HttpRequest header names 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 = 'HttpRequest header names must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_string( $value ) ) { $message = sprintf( 'HttpRequest header "%s" value must be a string; got %s.', $trimmedKey, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmedValue = trim( $value ); if ( $trimmedValue === '' ) { $message = sprintf( 'HttpRequest header "%s" must carry a non-empty value.', $trimmedKey ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmedKey ] = $trimmedValue; } return $normalized; } /** * Validate and normalize the query-parameters map. * * Keys are trimmed and rejected when empty; values must be * scalar or null. Nested arrays / objects are rejected because * the cross-provider contract expects a flat parameter surface * that adapters can render via `http_build_query()`. * * @param array $queryParameters Raw map. * * @return array * * @throws InvalidArgumentException When any entry violates the * per-entry rules. */ private static function normalizeQueryParameters( array $queryParameters ): array { $normalized = array(); foreach ( $queryParameters as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'HttpRequest query parameter names 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 = 'HttpRequest query parameter names 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( 'HttpRequest query parameter "%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; } /** * Validate the optional timeout value. * * @param int|null $timeoutSeconds Raw timeout. * * @throws InvalidArgumentException When `$timeoutSeconds` is * non-null and `< 1`. */ private static function normalizeTimeout( ?int $timeoutSeconds ): ?int { if ( $timeoutSeconds === null ) { return null; } if ( $timeoutSeconds < 1 ) { $message = sprintf( 'HttpRequest timeoutSeconds must be null or >= 1; got %d.', $timeoutSeconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $timeoutSeconds; } }