> */ public function listAccounts(AdsenseConnection $connection): array { return $this->paginate($connection, '/accounts', 'accounts'); } /** * @return list> */ public function listAdClients(AdsenseConnection $connection, string $accountResourceName): array { return $this->paginate($connection, '/'.$this->trimPath($accountResourceName).'/adclients', 'adClients'); } /** * @return list> */ public function listAdUnits(AdsenseConnection $connection, string $adClientResourceName): array { return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/adunits', 'adUnits'); } /** * @return list> */ public function listCustomChannels(AdsenseConnection $connection, string $adClientResourceName): array { return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/customchannels', 'customChannels'); } /** * @param list $dimensions * @param list $metrics * @return array */ public function generateReport( AdsenseConnection $connection, string $accountResourceName, array $dimensions, array $metrics, Carbon $from, Carbon $to, string $report = '', ): array { $path = '/'.$this->trimPath($accountResourceName).'/reports:generate'; $merged = [ 'headers' => [], 'rows' => [], ]; $pageToken = null; do { $query = $this->reportQuery($dimensions, $metrics, $from, $to, $pageToken); $json = $this->getJson($connection, $path, $query, $report); if ($merged['headers'] === [] && isset($json['headers']) && is_array($json['headers'])) { $merged['headers'] = $json['headers']; } $rows = $json['rows'] ?? []; if (is_array($rows) && $rows !== []) { $merged['rows'] = array_merge($merged['rows'], $rows); } foreach (['totals', 'averages', 'startDate', 'endDate', 'warnings'] as $metaKey) { if (! isset($merged[$metaKey]) && isset($json[$metaKey])) { $merged[$metaKey] = $json[$metaKey]; } } $pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== '' ? $json['nextPageToken'] : null; } while ($pageToken !== null); return $merged; } /** * @param array $query * @return list> */ private function paginate(AdsenseConnection $connection, string $path, string $itemsKey): array { $items = []; $pageToken = null; do { $query = []; if ($pageToken !== null) { $query['pageToken'] = $pageToken; } $json = $this->getJson($connection, $path, $query); $pageItems = $json[$itemsKey] ?? []; if (is_array($pageItems)) { foreach ($pageItems as $item) { if (is_array($item)) { $items[] = $item; } } } $pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== '' ? $json['nextPageToken'] : null; } while ($pageToken !== null); return $items; } /** * @param list $dimensions * @param list $metrics * @return array */ private function reportQuery(array $dimensions, array $metrics, Carbon $from, Carbon $to, ?string $pageToken): array { $query = [ 'startDate.year' => $from->year, 'startDate.month' => $from->month, 'startDate.day' => $from->day, 'endDate.year' => $to->year, 'endDate.month' => $to->month, 'endDate.day' => $to->day, ]; foreach ($dimensions as $index => $dimension) { $query['dimensions'.$index] = $dimension; } foreach ($metrics as $index => $metric) { $query['metrics'.$index] = $metric; } if ($pageToken !== null) { $query['pageToken'] = $pageToken; } return $query; } /** * @param array $query * @return array */ private function getJson(AdsenseConnection $connection, string $path, array $query = [], string $report = ''): array { $url = $this->url($path, $query); $response = $this->send($connection, $url, $report); $json = $response->json(); if (! is_array($json)) { throw new AdsenseApiException('Malformed AdSense API response.', $response->status(), $report); } return $json; } private function send(AdsenseConnection $connection, string $url, string $report = ''): Response { $maxAttempts = max(1, (int) config('adsense.retry_times', 3)); $sleepMs = max(0, (int) config('adsense.retry_sleep_ms', 400)); $refreshed = false; $attempt = 0; while (true) { $attempt++; try { $response = Http::acceptJson() ->withToken($this->oauth->accessToken($connection)) ->timeout((int) config('adsense.timeout', 20)) ->connectTimeout((int) config('adsense.connect_timeout', 5)) ->get($url); } catch (AdsenseAuthorizationException $exception) { throw $exception; } catch (ConnectionException $exception) { if ($attempt < $maxAttempts) { $this->backoff($sleepMs, $attempt); continue; } Log::warning('AdSense API connection failed.', AdsenseLogSanitizer::context([ 'message' => $exception->getMessage(), 'report' => $report, ])); throw new AdsenseApiException('AdSense API connection failed.', 0, $report, $exception); } $status = $response->status(); if ($status === 401 && ! $refreshed) { $this->oauth->forgetAccessToken((int) $connection->id); try { $this->oauth->accessToken($connection, true); } catch (AdsenseAuthorizationException $exception) { throw $exception; } $refreshed = true; $attempt--; continue; } if (in_array($status, [429, 500, 502, 503, 504], true) && $attempt < $maxAttempts) { $this->backoff($sleepMs, $attempt); continue; } if ($status === 401 || $status === 403) { $message = $this->errorMessage($response, 'AdSense API authorization failed.'); Log::warning('AdSense API authorization failed.', AdsenseLogSanitizer::context([ 'status' => $status, 'report' => $report, ])); if ($this->isRevoked($response, $message)) { $this->oauth->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.'); throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.'); } throw new AdsenseApiException($message, $status, $report); } if ($response->failed()) { $message = $this->errorMessage($response, 'AdSense API request failed.'); Log::warning('AdSense API request failed.', AdsenseLogSanitizer::context([ 'status' => $status, 'report' => $report, ])); throw new AdsenseApiException($message, $status, $report); } return $response; } } /** * @param array $query */ private function url(string $path, array $query): string { $base = rtrim((string) config('adsense.api_base'), '/'); $url = $base.'/'.$this->trimPath($path); $parts = []; foreach ($query as $key => $value) { if ($value === null || $value === '') { continue; } if (str_starts_with((string) $key, 'dimensions')) { $parts[] = 'dimensions='.rawurlencode((string) $value); continue; } if (str_starts_with((string) $key, 'metrics')) { $parts[] = 'metrics='.rawurlencode((string) $value); continue; } $parts[] = rawurlencode((string) $key).'='.rawurlencode((string) $value); } if ($parts === []) { return $url; } return $url.'?'.implode('&', $parts); } private function trimPath(string $path): string { return ltrim($path, '/'); } private function backoff(int $sleepMs, int $attempt): void { if ($sleepMs <= 0) { return; } usleep($sleepMs * 1000 * $attempt); } private function errorMessage(Response $response, string $fallback): string { $json = $response->json(); if (! is_array($json)) { return $fallback; } $error = $json['error'] ?? null; if (is_string($error) && $error !== '') { return $error; } if (is_array($error)) { $status = (string) ($error['status'] ?? ''); $message = (string) ($error['message'] ?? ''); $combined = trim($status.' '.$message); return $combined !== '' ? $combined : $fallback; } return $fallback; } private function isRevoked(Response $response, string $message): bool { $haystack = Str::lower($message.' '.$response->body()); return str_contains($haystack, 'invalid_grant') || str_contains($haystack, 'invalid credentials') || str_contains($haystack, 'revoked'); } }