PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / integrations / class-google-api-base-client.php

class-google-api-base-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/integrations/class-google-api-base-client.php

337 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Google API Base Client Class
5 *
6 * Abstract base class for Google API integrations providing common functionality
7 * for HTTP requests, rate limiting, error handling, and response processing.
8 * Follows ThinkRank patterns established by Claude_Client and OpenAI_Client.
9 *
10 * @package ThinkRank\Integrations
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Integrations;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Google API Base Client Class
25 *
26 * Single Responsibility: Provide common Google API functionality
27 * Following ThinkRank HTTP client patterns from Claude_Client and OpenAI_Client
28 *
29 * @since 1.0.0
30 */
31 abstract class Google_API_Base_Client {
32
33 /**
34 * API key
35 *
36 * @var string
37 */
38 protected string $api_key;
39
40 /**
41 * OAuth Access Token
42 *
43 * @var string|null
44 */
45 protected ?string $access_token = null;
46
47 /**
48 * Request timeout in seconds
49 *
50 * @var int
51 */
52 protected int $timeout;
53
54 /**
55 * Rate limit configuration
56 *
57 * @var array
58 */
59 protected array $rate_limits;
60
61 /**
62 * Constructor
63 *
64 * @param string $api_key Google API key
65 * @param int $timeout Request timeout in seconds
66 * @param string|null $access_token OAuth Access Token (optional)
67 */
68 public function __construct(string $api_key, int $timeout = 20, ?string $access_token = null) {
69 $this->api_key = $api_key;
70 $this->timeout = $timeout;
71 $this->access_token = $access_token;
72 $this->rate_limits = $this->get_rate_limits();
73 }
74
75 /**
76 * Make HTTP request to Google API
77 * Following Claude_Client and OpenAI_Client patterns
78 *
79 * @param string $url Full API URL
80 * @param array $params Request parameters
81 * @param string $method HTTP method
82 * @return array Response data
83 * @throws \Exception If request fails
84 */
85 protected function make_request(string $url, array $params = [], string $method = 'GET'): array {
86 // Check rate limiting before making request
87 $this->check_rate_limit();
88
89 $args = [
90 'timeout' => $this->timeout,
91 'headers' => [
92 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
93 ],
94 'method' => $method
95 ];
96
97 // Add OAuth Authorization header if token exists; otherwise fall back to
98 // the API key sent in the x-goog-api-key HEADER (never the query string,
99 // which is logged by servers, proxies and referrers).
100 if (!empty($this->access_token)) {
101 $args['headers']['Authorization'] = 'Bearer ' . $this->access_token;
102 } elseif (!empty($this->api_key)) {
103 $args['headers']['x-goog-api-key'] = $this->api_key;
104 }
105
106 // Defensive: never let a key travel in the query string.
107 unset($params['key']);
108
109 if ($method === 'GET' && !empty($params)) {
110 $url .= '?' . http_build_query($params);
111 } elseif ($method === 'POST') {
112 $args['body'] = wp_json_encode($params);
113 $args['headers']['Content-Type'] = 'application/json';
114 }
115
116 $response = wp_remote_request($url, $args);
117
118 if (is_wp_error($response)) {
119 // Not esc_html()'d: an exception message is data, not output. It is
120 // JSON-encoded to the REST layer and rendered as text by React, so
121 // escaping here only smuggled entities into what the user reads —
122 // Google's own wording is full of quotes, and the Performance panels
123 // displayed them as "quota metric &#039;Queries&#039;".
124 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Message is JSON data for the REST layer, escaped at render time by React.
125 throw new \Exception('API request failed: ' . $response->get_error_message());
126 }
127
128 $status_code = wp_remote_retrieve_response_code($response);
129 $response_body = wp_remote_retrieve_body($response);
130
131 if ($status_code >= 400) {
132 $error_data = json_decode($response_body, true);
133 $error_data = is_array($error_data) ? $error_data : [];
134 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
135
136 // A scope failure reads as "Request had insufficient authentication
137 // scopes." — Google-internal wording that told the user nothing and
138 // reached the MCP client verbatim (#674). Say what to do instead.
139 $actionable = self::actionable_auth_message((int) $status_code, $error_data);
140
141 if (null !== $actionable) {
142 // Google's own sentence is kept after the instruction: support
143 // needs the upstream wording to tell a scope failure from a
144 // revoked grant, and the user needs the instruction first.
145 // Built on its own line so the phpcs:ignore below sits on the
146 // `throw` itself. The annotation only suppresses the next line,
147 // and on a multi-line throw the reported violation is the
148 // argument line, not the `throw` — so the ignore missed it and
149 // Plugin Check failed on a sniff the repo standard does not run.
150 $message = sprintf('%s (Google said: %s)', $actionable, $error_message);
151
152 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
153 throw new \Exception($message, (int) $status_code);
154 }
155
156 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
157 throw new \Exception(sprintf('Google API error (%d): %s', (int) $status_code, $error_message), (int) $status_code);
158 }
159
160 $data = json_decode($response_body, true);
161
162 if (json_last_error() !== JSON_ERROR_NONE) {
163 throw new \Exception('Invalid JSON response from Google API');
164 }
165
166 // A valid-but-scalar body (null/number/string from a proxy/WAF/CDN on a
167 // 2xx) would violate this method's : array return type; reject it here so
168 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
169 if (!is_array($data)) {
170 throw new \Exception('Unexpected non-array response from Google API');
171 }
172
173 // Update rate limit tracking after successful request
174 $this->update_rate_limit();
175
176 return $data;
177 }
178
179 /**
180 * Test API connection
181 * Must be implemented by concrete classes
182 *
183 * @return array Connection test results
184 */
185 abstract public function test_connection(): array;
186
187 /**
188 * Get rate limit configuration
189 * Must be implemented by concrete classes
190 *
191 * @return array Rate limit configuration
192 */
193 abstract protected function get_rate_limits(): array;
194
195 /**
196 * Check if request is within rate limits
197 * Following ThinkRank rate limiting patterns from existing classes
198 *
199 * @throws \Exception If rate limit exceeded
200 */
201 protected function check_rate_limit(): void {
202 $rate_limit_key = $this->get_rate_limit_key();
203 $current_time = time();
204
205 // Reset counter if it's a new day
206 if ($current_time >= $this->rate_limits['reset_time']) {
207 $this->reset_rate_limit_counter();
208 }
209
210 $current_count = get_transient($rate_limit_key . '_count') ?: 0;
211
212 if ($current_count >= $this->rate_limits['max_requests_per_day']) {
213 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plugin-authored message, rendered as text by the admin app.
214 throw new \Exception($this->get_rate_limit_error_message());
215 }
216 }
217
218 /**
219 * Update rate limit counter after successful request
220 * Following ThinkRank transient patterns
221 */
222 protected function update_rate_limit(): void {
223 $rate_limit_key = $this->get_rate_limit_key();
224 $current_count = get_transient($rate_limit_key . '_count') ?: 0;
225 $new_count = $current_count + 1;
226
227 // Set transient to expire at end of day
228 $seconds_until_tomorrow = strtotime('tomorrow') - time();
229 set_transient($rate_limit_key . '_count', $new_count, $seconds_until_tomorrow);
230 }
231
232 /**
233 * Reset rate limit counter for new day
234 */
235 private function reset_rate_limit_counter(): void {
236 $rate_limit_key = $this->get_rate_limit_key();
237 delete_transient($rate_limit_key . '_count');
238 delete_transient($rate_limit_key . '_reset');
239
240 // Set new reset time for tomorrow
241 $seconds_until_tomorrow = strtotime('tomorrow') - time();
242 set_transient($rate_limit_key . '_reset', strtotime('tomorrow'), $seconds_until_tomorrow);
243 $this->rate_limits['reset_time'] = strtotime('tomorrow');
244 }
245
246 /**
247 * Get rate limit transient key
248 * Following ThinkRank option naming patterns
249 *
250 * @return string Rate limit key
251 */
252 abstract protected function get_rate_limit_key(): string;
253
254 /**
255 * Turn an authorization failure into an instruction, or null to pass through.
256 *
257 * Google answers a missing scope with "Request had insufficient
258 * authentication scopes." — accurate, and useless to the person reading it.
259 * It reached the MCP client and the Performance panels verbatim, with
260 * nothing to say that reconnecting the Google account is the fix (#674).
261 *
262 * The distinction that matters is refresh versus re-consent. A 401 is a
263 * stale access token and Analytics_Manager already refreshes and retries it
264 * silently. A scope 403 is not retryable: refreshing returns a token with
265 * the same scopes, so only granting consent again can change the outcome —
266 * which is why this says "reconnect", not "try again".
267 *
268 * Returns null for every other failure, so quota, rate-limit and genuine
269 * permission errors keep Google's wording, which is informative for them.
270 *
271 * @since 2.7.0
272 *
273 * @param int $status_code HTTP status.
274 * @param array $error_data Decoded error body.
275 * @return string|null Instruction to lead with, or null to pass through.
276 */
277 protected static function actionable_auth_message(int $status_code, array $error_data): ?string {
278 $error = is_array($error_data['error'] ?? null) ? $error_data['error'] : [];
279 $message = (string) ($error['message'] ?? '');
280
281 // google.rpc.ErrorInfo, which is what the newer APIs return.
282 $reasons = [];
283 foreach ((array) ($error['details'] ?? []) as $detail) {
284 if (is_array($detail) && isset($detail['reason'])) {
285 $reasons[] = (string) $detail['reason'];
286 }
287 }
288
289 // The older errors[] shape, still used by Search Console.
290 foreach ((array) ($error['errors'] ?? []) as $legacy) {
291 if (is_array($legacy) && isset($legacy['reason'])) {
292 $reasons[] = (string) $legacy['reason'];
293 }
294 }
295
296 $scope_failure = 403 === $status_code
297 && (
298 in_array('ACCESS_TOKEN_SCOPE_INSUFFICIENT', $reasons, true)
299 || in_array('insufficientPermissions', $reasons, true)
300 || 1 === preg_match('/insufficient (authentication scopes|permission)/i', $message)
301 );
302
303 if ($scope_failure) {
304 return __(
305 'The connected Google account is missing a permission this feature needs. Reconnect it under Essential SEO > Integrations and approve every permission Google asks for. Refreshing or retrying will not help, because the existing grant cannot gain a permission it was never given.',
306 'thinkrank'
307 );
308 }
309
310 // A revoked or withdrawn grant. Analytics_Manager treats invalid_grant
311 // as terminal already; this is the same condition seen from the API
312 // side, where the refresh-and-retry loop has nothing left to try.
313 $revoked = in_array($status_code, [401, 403], true)
314 && (
315 in_array('ACCESS_TOKEN_EXPIRED', $reasons, true)
316 || 1 === preg_match('/invalid[_ ]grant|token has been expired or revoked/i', $message)
317 );
318
319 if ($revoked) {
320 return __(
321 'The Google connection is no longer valid: access was revoked, or the grant expired. Reconnect the account under Essential SEO > Integrations.',
322 'thinkrank'
323 );
324 }
325
326 return null;
327 }
328
329 /**
330 * Get rate limit error message
331 * Must be implemented by concrete classes
332 *
333 * @return string Error message
334 */
335 abstract protected function get_rate_limit_error_message(): string;
336 }
337