PluginProbe
Auto Alt Text / trunk
Auto Alt Text vtrunk
3.0.3 2.8.2 1.3.1 1.3.2 2.0.0 2.1.0 2.1.1 2.2.0 2.3.0 2.3.1 2.3.2 2.3.3 2.3.4 2.4.0 2.4.1 2.4.2 2.5.0 2.5.1 2.5.2 2.5.3 2.6.0 2.6.1 2.7.0 2.8.0 2.8.1 All 28 releases
auto-alt-text / src / App / Infrastructure / Http / WordPressHttpClient.php

WordPressHttpClient.php in Auto Alt Text trunk, at src/App/Infrastructure/Http/WordPressHttpClient.php

202 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace AATXT\App\Infrastructure\Http;
6
7 use Exception;
8
9 /**
10 * WordPress HTTP client implementation.
11 *
12 * Wraps WordPress HTTP API functions (wp_remote_post, wp_remote_get) to provide
13 * a clean interface for dependency injection and testing.
14 */
15 class WordPressHttpClient implements HttpClientInterface
16 {
17 /**
18 * Default timeout for HTTP requests in seconds.
19 * Set to 90 seconds to accommodate AI provider API calls which can take longer.
20 */
21 private const DEFAULT_TIMEOUT = 90;
22
23 /**
24 * Filter controlling whether outbound requests are pinned to IPv4.
25 *
26 * Disable with: add_filter('aatxt_force_ipv4', '__return_false');
27 */
28 private const FILTER_FORCE_IPV4 = 'aatxt_force_ipv4';
29
30 /**
31 * Fragments AI providers use when they refuse a request because of the
32 * caller's geolocation. The wording differs per provider and per endpoint,
33 * so the body is matched loosely.
34 */
35 private const LOCATION_ERROR_MARKERS = [
36 'not available in your current location',
37 'user location is not supported',
38 ];
39
40 /**
41 * Perform an HTTP POST request using WordPress HTTP API.
42 *
43 * @param string $url The URL to send the request to
44 * @param array<string, string> $headers HTTP headers as key-value pairs
45 * @param array<string, mixed> $body Request body data (will be JSON-encoded if array)
46 *
47 * @return array<string, mixed> The decoded JSON response body
48 *
49 * @throws Exception If the HTTP request fails or returns a non-2xx status code
50 */
51 public function post(string $url, array $headers, array $body): array
52 {
53 $args = [
54 'headers' => $headers,
55 'body' => is_array($body) ? json_encode($body) : $body,
56 'timeout' => self::DEFAULT_TIMEOUT,
57 'data_format' => 'body',
58 ];
59
60 return $this->send($url, $args, true);
61 }
62
63 /**
64 * Perform an HTTP GET request using WordPress HTTP API.
65 *
66 * @param string $url The URL to send the request to
67 * @param array<string, string> $headers HTTP headers as key-value pairs
68 *
69 * @return array<string, mixed> The decoded JSON response body
70 *
71 * @throws Exception If the HTTP request fails or returns a non-2xx status code
72 */
73 public function get(string $url, array $headers = []): array
74 {
75 $args = [
76 'headers' => $headers,
77 'timeout' => self::DEFAULT_TIMEOUT,
78 ];
79
80 return $this->send($url, $args, false);
81 }
82
83 /**
84 * Dispatch the request and decode the response.
85 *
86 * @param string $url The URL to send the request to
87 * @param array<string, mixed> $args Arguments for the WordPress HTTP API
88 * @param bool $isPost True to POST, false to GET
89 *
90 * @return array<string, mixed> The decoded JSON response body
91 *
92 * @throws Exception If the HTTP request fails or returns a non-2xx status code
93 */
94 private function send(string $url, array $args, bool $isPost): array
95 {
96 $ipv4Callback = $this->pinToIpv4($url);
97
98 try {
99 $response = $isPost ? wp_remote_post($url, $args) : wp_remote_get($url, $args);
100 } finally {
101 if ($ipv4Callback !== null) {
102 remove_action('http_api_curl', $ipv4Callback, 10);
103 }
104 }
105
106 // Check for WordPress HTTP errors
107 if (is_wp_error($response)) {
108 throw new Exception(
109 sprintf('HTTP request failed: %s', $response->get_error_message())
110 );
111 }
112
113 // Check HTTP status code
114 $statusCode = wp_remote_retrieve_response_code($response);
115 if ($statusCode < 200 || $statusCode >= 300) {
116 throw new Exception(
117 $this->describeStatusError((int)$statusCode, wp_remote_retrieve_body($response))
118 );
119 }
120
121 // Retrieve and decode response body
122 $responseBody = wp_remote_retrieve_body($response);
123 if (empty($responseBody)) {
124 throw new Exception('Empty response body received from server');
125 }
126
127 $decodedBody = json_decode($responseBody, true);
128 if (json_last_error() !== JSON_ERROR_NONE) {
129 throw new Exception(
130 sprintf('Failed to decode JSON response: %s', json_last_error_msg())
131 );
132 }
133
134 return $decodedBody;
135 }
136
137 /**
138 * Pin the next outbound request to IPv4 for the duration of the call.
139 *
140 * cURL prefers the AAAA record, and several providers - Google most visibly -
141 * geolocate datacenter IPv6 ranges (OVH and Hetzner among them) outside the
142 * regions where their API is offered. The same request then succeeds over
143 * IPv4 and fails over IPv6 with an HTTP 400 about the caller's location, so
144 * generation breaks depending on the host's network rather than on anything
145 * the user configured. Pinning to IPv4 keeps the behaviour predictable.
146 *
147 * @param string $url The URL about to be requested
148 *
149 * @return callable|null The registered callback, to be removed after the
150 * request, or null when the filter opted out
151 */
152 private function pinToIpv4(string $url): ?callable
153 {
154 if (!apply_filters(self::FILTER_FORCE_IPV4, true, $url)) {
155 return null;
156 }
157
158 $callback = static function ($handle) {
159 if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) {
160 curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
161 }
162 };
163
164 add_action('http_api_curl', $callback, 10, 1);
165
166 return $callback;
167 }
168
169 /**
170 * Build the exception message for a non-2xx response, adding a hint when the
171 * provider refused the request because of where it was called from. The raw
172 * message points at the account or the region and sends people looking in the
173 * wrong place, while the actual cause is almost always the outbound IP.
174 *
175 * @param int $statusCode The HTTP status code received
176 * @param string $body The raw response body
177 *
178 * @return string The message to report
179 */
180 private function describeStatusError(int $statusCode, string $body): string
181 {
182 $message = sprintf(
183 'HTTP request returned status %d: %s',
184 $statusCode,
185 $body ?: 'No response body'
186 );
187
188 foreach (self::LOCATION_ERROR_MARKERS as $marker) {
189 if (stripos($body, $marker) !== false) {
190 return $message . ' | The provider refused the request based on the geolocation of the '
191 . 'IP this server sent it from, not on your API key or plan. This usually means the '
192 . 'call left over IPv6 from a datacenter range the provider maps outside its supported '
193 . 'regions. The plugin pins requests to IPv4 to avoid this; if the "'
194 . self::FILTER_FORCE_IPV4 . '" filter has been disabled, re-enable it, and otherwise '
195 . 'check that this server\'s outbound IPv4 address is in a supported country.';
196 }
197 }
198
199 return $message;
200 }
201 }
202