= 1` when non-null, so the integer round-trip is * safe. * * - Transport-level failures (`is_wp_error()` true on the response, * non-array response shape, missing status code) are translated * into {@see HttpClientException} carrying the originating * {@see HttpRequest}. Application-level failures (non-2xx status * codes, malformed bodies) round-trip to {@see HttpResponse} * values per the {@see HttpClient} contract — the provider * error mapper handles them downstream, not this adapter. * * - `wp_remote_*` is typed indirectly because this file must stay * loadable in pure-unit tests that run outside a WordPress * environment. Calls go through `function_exists()` guards so * the test bootstrap can stub `wp_remote_request()` without * having to register WordPress's full HTTP API. * * @package Affilun\Infrastructure\Http */ declare(strict_types=1); // Exception messages constructed in this adapter are developer-facing // transport-failure summaries surfaced through HttpClientException; // they never reach an HTML rendering path. Suppress the EscapeOutput // sniff at the file level rather than peppering every throw site // with a per-line ignore. // // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped namespace Affilun\Infrastructure\Http; use Affilun\Domain\Http\HttpClient; use Affilun\Domain\Http\HttpClientException; use Affilun\Domain\Http\HttpRequest; use Affilun\Domain\Http\HttpResponse; use Throwable; /** * {@see HttpClient} implementation backed by WordPress's * `wp_remote_request()` function. * * See the file-level docblock for the dispatch strategy, the * default-headers layering rule, and the boundary between transport * failures (translated to {@see HttpClientException}) and * application-level failures (returned as non-2xx * {@see HttpResponse} values). */ final class WpRemoteHttpClient implements HttpClient { /** * Default `User-Agent` token applied to every request unless * the caller's {@see HttpRequest} overrides it. * * The token follows the RFC 9110 "product/version" shape and * intentionally identifies the plugin so upstream rate limiters * can attribute traffic correctly. Provider implementations that * want a more specific identifier (Amazon's Associates Program * recommends including the partner tag, for example) can stamp * it via {@see HttpRequest::withHeader()} on top of the request. */ public const DEFAULT_USER_AGENT = 'Affilun/0.1'; /** * Header map merged into every dispatched request before the * caller's headers are applied. * * Held verbatim with whatever casing the caller (typically the * service-provider boot path) supplied; lookup is case- * insensitive on the {@see HttpRequest} accessor side, so the * casing only affects how the headers render on the wire. * * @var array */ private array $defaultHeaders; /** * Construct the adapter. * * @param array $defaultHeaders Header map merged * into every request. * Defaults to a single * `User-Agent` entry * carrying * {@see self::DEFAULT_USER_AGENT}. */ public function __construct( array $defaultHeaders = array() ) { if ( $defaultHeaders === array() ) { $defaultHeaders = array( 'User-Agent' => self::DEFAULT_USER_AGENT ); } $this->defaultHeaders = $defaultHeaders; } /** * Dispatch the supplied request through `wp_remote_request()` * and translate the result back into an {@see HttpResponse}. * * See {@see HttpClient::send()} for the cross-implementation * contract. This implementation: * * - Merges the adapter's default headers with the request's * own headers, with the request author always winning when * both supply a value for the same header (case-insensitive * on the comparison, casing preserved on the wire). * * - Renders the request URL by combining * {@see HttpRequest::url()} with * {@see HttpRequest::queryParameters()} via WordPress's * `add_query_arg()` when available, falling back to a * manual `http_build_query()` join for pure-unit test * contexts where `add_query_arg()` is not loaded. * * - Routes the call through `wp_remote_request()` and inspects * the result with `is_wp_error()`. A `WP_Error` result is a * transport-level failure — the adapter raises * {@see HttpClientException} carrying the original request. * A non-array success shape, or one missing the response * status code, is treated the same way. * * - On a successful round-trip, extracts the integer status * code via `wp_remote_retrieve_response_code()`, the headers * via `wp_remote_retrieve_headers()`, and the body via * `wp_remote_retrieve_body()`, and constructs an * {@see HttpResponse} from them. * * @param HttpRequest $request Request to dispatch. * * @throws HttpClientException When `wp_remote_request()` reports * a transport-level failure or * returns a response shape this * adapter cannot translate. */ public function send( HttpRequest $request ): HttpResponse { if ( ! function_exists( 'wp_remote_request' ) ) { throw new HttpClientException( $request, 'WpRemoteHttpClient requires WordPress\'s wp_remote_request() function.' ); } $url = $this->renderUrl( $request ); $args = $this->buildArgs( $request ); $result = null; $failure = null; try { /** @var array|\WP_Error $result */ $result = wp_remote_request( $url, $args ); } catch ( Throwable $error ) { $failure = $error; } if ( $failure !== null ) { throw new HttpClientException( $request, sprintf( 'wp_remote_request() raised %s: %s', get_class( $failure ), $failure->getMessage() ), 0, $failure ); } if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) { $message = function_exists( 'method_exists' ) && method_exists( $result, 'get_error_message' ) ? (string) $result->get_error_message() : 'wp_remote_request() returned a WP_Error.'; throw new HttpClientException( $request, sprintf( 'wp_remote_request() failed: %s', $message ) ); } if ( ! is_array( $result ) ) { throw new HttpClientException( $request, sprintf( 'wp_remote_request() returned an unexpected shape: %s', gettype( $result ) ) ); } $statusCode = $this->extractStatusCode( $request, $result ); $headers = $this->extractHeaders( $result ); $body = $this->extractBody( $result ); return new HttpResponse( $statusCode, $headers, $body ); } /** * Render the final request URL by combining * {@see HttpRequest::url()} with * {@see HttpRequest::queryParameters()}. * * Uses WordPress's `add_query_arg()` when available so any * pre-existing query string on the URL is merged correctly * (`add_query_arg()` knows how to detect duplicate keys and * preserve URL fragments). Falls back to `http_build_query()` * when the WordPress helper is not loaded — typical for * pure-unit test contexts that stub only `wp_remote_request()`. * * @param HttpRequest $request Request to render. */ private function renderUrl( HttpRequest $request ): string { $base = $request->url(); $params = $request->queryParameters(); if ( $params === array() ) { return $base; } if ( function_exists( 'add_query_arg' ) ) { /** @var string $merged */ $merged = add_query_arg( $params, $base ); return $merged; } $query = http_build_query( $params ); $separator = strpos( $base, '?' ) === false ? '?' : '&'; return $base . $separator . $query; } /** * Build the `wp_remote_request()` arguments array from the * supplied {@see HttpRequest}. * * Forwards the method verbatim, merges the adapter's default * headers under the caller's headers (case-insensitive * comparison; the caller always wins when both supply the same * header), preserves the body verbatim including the empty * string, and forwards the timeout when set. * * @param HttpRequest $request Request to translate. * * @return array */ private function buildArgs( HttpRequest $request ): array { $args = array( 'method' => $request->method(), 'headers' => $this->mergeHeaders( $request ), ); $body = $request->body(); if ( $body !== null ) { $args['body'] = $body; } $timeout = $request->timeoutSeconds(); if ( $timeout !== null ) { $args['timeout'] = $timeout; } return $args; } /** * Merge the adapter's default headers with the caller's headers. * * The caller's headers always win when both supply a value for * the same header. Comparison is case-insensitive on the header * name (HTTP header names are case-insensitive on the wire) but * the resulting map preserves the caller's casing because some * upstreams inspect their own header echoes verbatim. * * @param HttpRequest $request Request whose headers to merge. * * @return array */ private function mergeHeaders( HttpRequest $request ): array { $callerHeaders = $request->headers(); if ( $callerHeaders === array() ) { return $this->defaultHeaders; } $callerKeys = array(); foreach ( $callerHeaders as $name => $unused ) { unset( $unused ); $callerKeys[ strtolower( $name ) ] = true; } $merged = array(); foreach ( $this->defaultHeaders as $name => $value ) { if ( ! array_key_exists( strtolower( $name ), $callerKeys ) ) { $merged[ $name ] = $value; } } foreach ( $callerHeaders as $name => $value ) { $merged[ $name ] = $value; } return $merged; } /** * Extract the integer status code from a `wp_remote_request()` * result array. * * Uses `wp_remote_retrieve_response_code()` when available so * the result honors WordPress's normal extraction shape. Falls * back to a direct array probe for pure-unit test contexts * where the helper is not loaded. * * @param HttpRequest $request Originating request, preserved * on the failure path. * @param array $result `wp_remote_request()` result. * * @throws HttpClientException When the result does not carry a * positive integer status code. */ private function extractStatusCode( HttpRequest $request, array $result ): int { if ( function_exists( 'wp_remote_retrieve_response_code' ) ) { $code = wp_remote_retrieve_response_code( $result ); if ( is_int( $code ) && $code > 0 ) { return $code; } if ( is_string( $code ) && ctype_digit( $code ) && $code !== '' ) { return (int) $code; } } if ( isset( $result['response']['code'] ) ) { $code = $result['response']['code']; if ( is_int( $code ) && $code > 0 ) { return $code; } if ( is_string( $code ) && ctype_digit( $code ) && $code !== '' ) { return (int) $code; } } throw new HttpClientException( $request, 'wp_remote_request() result is missing a usable response status code.' ); } /** * Extract the headers map from a `wp_remote_request()` result * array. * * Uses `wp_remote_retrieve_headers()` when available; falls back * to a direct array probe for pure-unit test contexts. The * helper may return either a plain associative array or a * `Requests_Utility_CaseInsensitiveDictionary` (older WordPress) * / `WpOrg\Requests\Utility\CaseInsensitiveDictionary` * (newer WordPress) — both implement `IteratorAggregate` so we * can iterate without binding to a specific class. * * Header values that are not strings (e.g. arrays, when the * underlying upstream emitted multi-value headers) are folded * with `, ` per RFC 9110 §5.3 so the {@see HttpResponse} * constructor's `array` invariant holds. * * @param array $result `wp_remote_request()` result. * * @return array */ private function extractHeaders( array $result ): array { $source = null; if ( function_exists( 'wp_remote_retrieve_headers' ) ) { $source = wp_remote_retrieve_headers( $result ); } elseif ( isset( $result['headers'] ) ) { $source = $result['headers']; } if ( $source === null ) { return array(); } $entries = array(); if ( is_array( $source ) ) { $entries = $source; } elseif ( is_iterable( $source ) ) { foreach ( $source as $name => $value ) { $entries[ $name ] = $value; } } else { return array(); } $normalized = array(); foreach ( $entries as $name => $value ) { if ( ! is_string( $name ) ) { continue; } $trimmedName = trim( $name ); if ( $trimmedName === '' ) { continue; } if ( is_array( $value ) ) { $flat = array(); foreach ( $value as $entry ) { if ( is_scalar( $entry ) ) { $flat[] = (string) $entry; } } $value = implode( ', ', $flat ); } elseif ( is_scalar( $value ) ) { $value = (string) $value; } else { continue; } $trimmedValue = trim( $value ); if ( $trimmedValue === '' ) { continue; } $normalized[ $trimmedName ] = $trimmedValue; } return $normalized; } /** * Extract the body string from a `wp_remote_request()` result * array. * * Uses `wp_remote_retrieve_body()` when available; falls back * to a direct array probe for pure-unit test contexts. A * missing body is normalized to the empty string so the * {@see HttpResponse} constructor's "empty-body responses are * encoded as `''`, not null" invariant holds. * * @param array $result `wp_remote_request()` result. */ private function extractBody( array $result ): string { if ( function_exists( 'wp_remote_retrieve_body' ) ) { $body = wp_remote_retrieve_body( $result ); if ( is_string( $body ) ) { return $body; } } if ( isset( $result['body'] ) && is_string( $result['body'] ) ) { return $result['body']; } return ''; } }