Files
SkinbaseNova/app/Services/Adsense/AdsenseApiClient.php
T
klevze ce0f278bac Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
2026-09-20 14:49:48 +02:00

351 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Services\Adsense\Exceptions\AdsenseApiException;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class AdsenseApiClient
{
public function __construct(private readonly AdsenseOAuthService $oauth) {}
/**
* @return list<array<string, mixed>>
*/
public function listAccounts(AdsenseConnection $connection): array
{
return $this->paginate($connection, '/accounts', 'accounts');
}
/**
* @return list<array<string, mixed>>
*/
public function listAdClients(AdsenseConnection $connection, string $accountResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($accountResourceName).'/adclients', 'adClients');
}
/**
* @return list<array<string, mixed>>
*/
public function listAdUnits(AdsenseConnection $connection, string $adClientResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/adunits', 'adUnits');
}
/**
* @return list<array<string, mixed>>
*/
public function listCustomChannels(AdsenseConnection $connection, string $adClientResourceName): array
{
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/customchannels', 'customChannels');
}
/**
* @param list<string> $dimensions
* @param list<string> $metrics
* @return array<string, mixed>
*/
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<string, scalar|null> $query
* @return list<array<string, mixed>>
*/
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<string> $dimensions
* @param list<string> $metrics
* @return array<string, scalar>
*/
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<string, scalar|null> $query
* @return array<string, mixed>
*/
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<string, scalar|null> $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');
}
}