search a ads
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
"""Google Analytics client.
|
||||
"""Shared Google API client used by Analytics, Search Console and Google Ads.
|
||||
|
||||
Thin proxy over the GA4 Data API and Admin API. Request/response bodies are
|
||||
forwarded as-is so callers keep the full flexibility of Google's API; this
|
||||
module only handles authentication (Bearer token), the base URL, and error
|
||||
A single Google OAuth mechanism backs all three services - the only difference
|
||||
is the OAuth *scope* (carried on ``GoogleCredentials.scope``) and, for Google
|
||||
Ads, a couple of extra headers. Request/response bodies are forwarded as-is so
|
||||
callers keep the full upstream API surface; this module only handles
|
||||
authentication (Bearer token), the optional quota-project header and error
|
||||
mapping.
|
||||
|
||||
Authentication (per chosen model, token wins over service account):
|
||||
* If ``X-GA-Access-Token`` was supplied, it is used directly.
|
||||
* Otherwise a short-lived access token is minted from the service-account
|
||||
JSON via google-auth and cached in-memory (keyed by key id + scope) until
|
||||
shortly before it expires. The key material is never written to disk or log.
|
||||
Auth (token wins over service account):
|
||||
* If an access token was supplied in the header, it is used directly.
|
||||
* Otherwise a short-lived access token is minted from the service-account JSON
|
||||
via google-auth for the requested scope and cached in-memory (keyed by
|
||||
key id + email + scope) until shortly before it expires. Key material is
|
||||
never written to disk or log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,7 +25,7 @@ import httpx
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from .. import config
|
||||
from ..credentials import GaCredentials
|
||||
from ..credentials import GoogleCredentials
|
||||
from ..errors import MissingCredentialsError, UpstreamError
|
||||
from ..logging_config import get_logger
|
||||
|
||||
@@ -50,7 +53,7 @@ def _mint_token_sync(info: dict, scope: str) -> tuple[str, float]:
|
||||
)
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise MissingCredentialsError(
|
||||
f"X-GA-Credentials is not a usable service-account key: {exc}"
|
||||
f"Service-account credentials are not usable: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
@@ -73,17 +76,17 @@ def _cache_key(info: dict, scope: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _bearer_token(creds: GaCredentials) -> str:
|
||||
async def bearer_token(creds: GoogleCredentials) -> str:
|
||||
if creds.access_token:
|
||||
return creds.access_token
|
||||
|
||||
if creds.service_account_info is None:
|
||||
# get_ga_credentials guarantees one of the two, but be defensive.
|
||||
# Credential dependencies guarantee one of the two, but be defensive.
|
||||
raise MissingCredentialsError(
|
||||
"No GA access token and no service-account credentials available."
|
||||
"No Google access token and no service-account credentials available."
|
||||
)
|
||||
|
||||
scope = config.GA_SCOPE
|
||||
scope = creds.scope
|
||||
key = _cache_key(creds.service_account_info, scope)
|
||||
now = time.time()
|
||||
|
||||
@@ -102,27 +105,38 @@ async def _bearer_token(creds: GaCredentials) -> str:
|
||||
return token
|
||||
|
||||
|
||||
class GoogleAnalyticsClient:
|
||||
"""Authenticated HTTP client for the GA4 Data and Admin APIs."""
|
||||
class GoogleApiClient:
|
||||
"""Authenticated HTTP client for any Google REST API.
|
||||
|
||||
def __init__(self, creds: GaCredentials) -> None:
|
||||
``extra_headers`` lets callers add upstream-specific headers (e.g. the
|
||||
Google Ads ``developer-token`` / ``login-customer-id``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
creds: GoogleCredentials,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
*,
|
||||
service_name: str = "Google",
|
||||
) -> None:
|
||||
self._creds = creds
|
||||
self._extra_headers = extra_headers or {}
|
||||
self._service_name = service_name
|
||||
|
||||
async def _request(
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
base_url: str,
|
||||
path: str,
|
||||
url: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json_body: Any | None = None,
|
||||
) -> Any:
|
||||
token = await _bearer_token(self._creds)
|
||||
token = await bearer_token(self._creds)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if self._creds.quota_project:
|
||||
headers["x-goog-user-project"] = self._creds.quota_project
|
||||
headers.update(self._extra_headers)
|
||||
|
||||
url = f"{base_url}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=config.HTTP_TIMEOUT_SECONDS
|
||||
@@ -132,34 +146,23 @@ class GoogleAnalyticsClient:
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise UpstreamError(
|
||||
"Google Analytics request timed out.", status=504
|
||||
f"{self._service_name} request timed out.", status=504
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpstreamError(
|
||||
f"Google Analytics is unreachable: {exc}", status=502
|
||||
f"{self._service_name} is unreachable: {exc}", status=502
|
||||
) from exc
|
||||
|
||||
return _parse_google_response(resp)
|
||||
return _parse_google_response(resp, self._service_name)
|
||||
|
||||
# --- Data API -------------------------------------------------------------
|
||||
async def data_post(self, path: str, body: Any) -> Any:
|
||||
return await self._request(
|
||||
"POST", config.GA_DATA_BASE_URL, path, json_body=body
|
||||
)
|
||||
async def get(self, url: str, params: dict | None = None) -> Any:
|
||||
return await self.request("GET", url, params=params)
|
||||
|
||||
async def data_get(self, path: str, params: dict | None = None) -> Any:
|
||||
return await self._request(
|
||||
"GET", config.GA_DATA_BASE_URL, path, params=params
|
||||
)
|
||||
|
||||
# --- Admin API ------------------------------------------------------------
|
||||
async def admin_get(self, path: str, params: dict | None = None) -> Any:
|
||||
return await self._request(
|
||||
"GET", config.GA_ADMIN_BASE_URL, path, params=params
|
||||
)
|
||||
async def post(self, url: str, json_body: Any) -> Any:
|
||||
return await self.request("POST", url, json_body=json_body)
|
||||
|
||||
|
||||
def _parse_google_response(resp: httpx.Response) -> Any:
|
||||
def _parse_google_response(resp: httpx.Response, service_name: str) -> Any:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
@@ -168,10 +171,17 @@ def _parse_google_response(resp: httpx.Response) -> Any:
|
||||
if resp.is_success:
|
||||
return payload
|
||||
|
||||
# Google returns {"error": {"code", "message", "status", ...}}.
|
||||
message = "Google Analytics API error"
|
||||
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
||||
message = payload["error"].get("message", message)
|
||||
# Google returns {"error": {"code", "message", "status", ...}}; Google Ads
|
||||
# streaming returns a list whose first element may carry the error.
|
||||
message = f"{service_name} API error"
|
||||
err_obj: Any = None
|
||||
if isinstance(payload, dict):
|
||||
err_obj = payload.get("error")
|
||||
elif isinstance(payload, list) and payload and isinstance(payload[0], dict):
|
||||
err_obj = payload[0].get("error")
|
||||
if isinstance(err_obj, dict) and err_obj.get("message"):
|
||||
message = err_obj["message"]
|
||||
|
||||
raise UpstreamError(
|
||||
message,
|
||||
status=502 if resp.status_code >= 500 else resp.status_code,
|
||||
Reference in New Issue
Block a user