= 100 && <= 599` because that is the full HTTP * status-code range; the DTO does NOT close the set further * because RFC 9110 explicitly allows experimental codes and * unknown providers may emit them. Provider error mappers branch * on the value, not on a closed enum. * * - Headers are held as an associative `array` * keyed by header name. Names are trimmed but case is preserved * so fixture-based testing stays trivial — call sites that need * case-insensitive lookup go through {@see self::header()}. * Multi-value headers are NOT modeled at this layer; the rare * `Set-Cookie` case is a session concern that the existing * provider contracts do not need to cover. * * - The body is held as a non-null string. An empty-body response * is encoded as `body() === ''` rather than null so callers do * not have to coalesce on every read; `Content-Length: 0` * responses are legitimate and distinct from "no body * transferred at all" only in pathological cases the provider * layer does not need to distinguish. * * - Convenience accessors ({@see self::isSuccessful()}, * {@see self::isClientError()}, {@see self::isServerError()}) * branch on the status-code class without forcing every call * site to remember the 2xx / 4xx / 5xx ranges. The methods * follow RFC 9110 §15: 200-299 inclusive is success, 400-499 is * client error, 500-599 is server error. The 1xx and 3xx ranges * are not exposed as helpers because the existing provider * contracts do not branch on them — `wp_remote_*` follows * redirects automatically and 1xx informational responses are * never user-visible. * * - JSON decoding is exposed as a dedicated helper * ({@see self::jsonBody()}) because every cross-provider * adapter in Phase 4-12 deals with JSON payloads. The helper * returns the decoded value or raises * {@see InvalidArgumentException} carrying the JSON parse error * verbatim, so provider implementations can let the failure * propagate to a {@see \Affilun\Domain\Provider\Exception\ProviderResponseException} * without re-implementing the decode-and-validate dance. * * Instances are immutable. There are no `with*()` mutation helpers * because a response represents a completed HTTP call: any change * would conflate "this is what the upstream returned" with "this is * what we wish it had returned", which is exactly the seam the * provider error mapper is meant to close. * * @package Affilun\Domain\Http */ declare(strict_types=1); namespace Affilun\Domain\Http; use InvalidArgumentException; /** * Immutable description of a single completed HTTP response. * * See the file-level docblock for the driver-agnostic field design, * the per-field validation rules, and the rationale for keeping the * type free of mutation helpers. */ final class HttpResponse { /** * Inclusive lower bound of the HTTP status-code range. * * Per RFC 9110 §15, status codes below 100 are not assigned and * SHOULD NOT appear in a real response. */ private const STATUS_MIN = 100; /** * Inclusive upper bound of the HTTP status-code range. * * Per RFC 9110 §15, status codes above 599 are reserved and * SHOULD NOT appear in a real response. */ private const STATUS_MAX = 599; /** * Integer HTTP status code in the range [100, 599]. */ private int $statusCode; /** * Header map keyed by header name (verbatim casing preserved). * * @var array */ private array $headers; /** * Response body, possibly empty but never null. */ private string $body; /** * Construct a response with explicit values for every field. * * Validation summary: * * - `statusCode` is clamped to [100, 599] inclusive; values * outside that range raise {@see InvalidArgumentException}. * - `headers` keys are trimmed and rejected when empty; * values are trimmed and rejected when empty (use the * "no header" form by simply omitting the entry). * - `body` is preserved verbatim including the empty string. * * @param int $statusCode Integer HTTP status code. * @param array $headers Header map. * @param string $body Response body. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( int $statusCode, array $headers = array(), string $body = '' ) { if ( $statusCode < self::STATUS_MIN || $statusCode > self::STATUS_MAX ) { $message = sprintf( 'HttpResponse statusCode must be in the range [%d, %d]; got %d.', self::STATUS_MIN, self::STATUS_MAX, $statusCode ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->statusCode = $statusCode; $this->headers = self::normalizeHeaders( $headers ); $this->body = $body; } /** * Integer HTTP status code. */ public function statusCode(): int { return $this->statusCode; } /** * 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 `Content-Type` regardless of the casing the * upstream used. 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; } /** * Response body, possibly empty but never null. */ public function body(): string { return $this->body; } /** * Report whether the response carries a 2xx success status. * * Per RFC 9110 §15.3, the 2xx range indicates the request was * successfully received, understood, and accepted. Provider * implementations branch on this helper to decide whether to * decode the response body or to map the status to one of the * §10.2 exceptions. */ public function isSuccessful(): bool { return $this->statusCode >= 200 && $this->statusCode <= 299; } /** * Report whether the response carries a 4xx client-error status. * * Per RFC 9110 §15.5, the 4xx range indicates the request * itself was rejected. Provider error mappers typically branch * on the specific code (401 → AuthenticationException, 404 → * ProductNotFoundException, 429 → RateLimitExceededException) * but this helper is useful for the "any client error" guard. */ public function isClientError(): bool { return $this->statusCode >= 400 && $this->statusCode <= 499; } /** * Report whether the response carries a 5xx server-error status. * * Per RFC 9110 §15.6, the 5xx range indicates the upstream * failed to fulfill the request. Provider error mappers * typically translate the entire range to * {@see \Affilun\Domain\Provider\Exception\TemporaryUnavailableException} * because retry is reasonable for every 5xx code. */ public function isServerError(): bool { return $this->statusCode >= 500 && $this->statusCode <= 599; } /** * Decode the response body as JSON and return the resulting * value. * * The helper uses associative arrays (not stdClass objects) so * downstream provider normalizers can use `array_key_exists()` * and `??` without re-shaping the decoded value. The depth limit * is left at PHP's default (512) because cross-provider response * payloads are nowhere near that deep; constraining further * would only mask genuine contract drift. * * Provider implementations typically wrap the * {@see InvalidArgumentException} this method raises into a * {@see \Affilun\Domain\Provider\Exception\ProviderResponseException} * carrying the response status / digest in the exception * context, per the §10.2 error contract. * * @return mixed Decoded JSON value (typically an associative * array, but a JSON `null` / scalar / list is * also valid per RFC 8259). * * @throws InvalidArgumentException When the body cannot be * decoded as JSON. The * exception message carries * the underlying parse error * verbatim for diagnosis. */ public function jsonBody() { if ( $this->body === '' ) { $message = 'HttpResponse jsonBody() cannot decode an empty body.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } /** @var mixed $decoded */ $decoded = json_decode( $this->body, true ); if ( $decoded === null && json_last_error() !== JSON_ERROR_NONE ) { $message = sprintf( 'HttpResponse jsonBody() failed to decode body: %s', json_last_error_msg() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $decoded; } /** * Validate and normalize the headers map at construction. * * Mirrors the {@see HttpRequest} header-normalization rules so * the two DTOs stay symmetric: 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( 'HttpResponse 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 = 'HttpResponse 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( 'HttpResponse 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( 'HttpResponse 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; } }