search a ads

This commit is contained in:
JiriUhlir
2026-06-18 12:43:42 +02:00
parent 9cd5c8011b
commit 125b112967
12 changed files with 748 additions and 191 deletions
+157 -42
View File
@@ -6,16 +6,17 @@ supplied per request as an X- header and used only to talk to the upstream API
``Header`` parameters makes them appear per-operation in Swagger, including the
"Try it out" form.
Google Analytics (chosen model: token has precedence over service account):
* ``X-GA-Access-Token`` - a ready OAuth2 access token; used directly as Bearer.
* ``X-GA-Credentials`` - base64-encoded service-account JSON key; the proxy
mints a short-lived access token from it.
* ``X-GA-Quota-Project`` - optional billing/quota project id.
At least one of token / credentials must be present.
All three Google services share the same OAuth mechanism (token wins over
service account); they differ only in the OAuth *scope* and the header prefix:
Sklik:
* ``X-Sklik-Token`` - the Sklik API token from account settings. The proxy
calls ``client.loginByToken`` to obtain a session.
* Google Analytics -> ``X-GA-*`` (scope analytics.readonly)
* Search Console -> ``X-GSC-*`` (scope webmasters.readonly)
* Google Ads -> ``X-GAds-*`` (scope adwords) + developer token
Each provides ``*-Access-Token`` (ready Bearer token, takes precedence) and
``*-Credentials`` (base64 service-account JSON; the proxy mints a token).
Sklik uses ``X-Sklik-Token`` (the proxy calls client.loginByToken).
"""
from __future__ import annotations
@@ -26,17 +27,72 @@ from dataclasses import dataclass
from fastapi import Header
from . import config
from .errors import MissingCredentialsError
# --- Google Analytics ---------------------------------------------------------
# --- Google (shared) ----------------------------------------------------------
@dataclass
class GaCredentials:
class GoogleCredentials:
access_token: str | None
service_account_info: dict | None
quota_project: str | None
scope: str
@dataclass
class GoogleAdsCredentials:
google: GoogleCredentials
developer_token: str
login_customer_id: str | None
def _build_google_credentials(
access_token: str | None,
raw_credentials: str | None,
quota_project: str | None,
scope: str,
*,
token_header: str,
creds_header: str,
) -> GoogleCredentials:
"""Parse a Google access token / base64 service-account JSON from headers."""
token = (access_token or "").strip() or None
service_account_info: dict | None = None
raw = (raw_credentials or "").strip()
if raw:
try:
decoded = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise MissingCredentialsError(
f"{creds_header} is not valid base64."
) from exc
try:
service_account_info = json.loads(decoded)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise MissingCredentialsError(
f"{creds_header} does not decode to valid JSON."
) from exc
if not isinstance(service_account_info, dict):
raise MissingCredentialsError(
f"{creds_header} JSON must be a service-account object."
)
if not token and service_account_info is None:
raise MissingCredentialsError(
f"Provide either {token_header} or {creds_header}."
)
return GoogleCredentials(
access_token=token,
service_account_info=service_account_info,
quota_project=(quota_project or "").strip() or None,
scope=scope,
)
# --- Google Analytics ---------------------------------------------------------
def get_ga_credentials(
x_ga_access_token: str | None = Header(
default=None,
@@ -54,42 +110,101 @@ def get_ga_credentials(
x_ga_quota_project: str | None = Header(
default=None,
alias="X-GA-Quota-Project",
description="Optional Google Cloud project id used for quota/billing "
description="Optional Google Cloud project id for quota/billing "
"(sets the x-goog-user-project header upstream).",
),
) -> GaCredentials:
"""Resolve GA credentials from headers. Token wins over service account."""
access_token = (x_ga_access_token or "").strip() or None
) -> GoogleCredentials:
return _build_google_credentials(
x_ga_access_token,
x_ga_credentials,
x_ga_quota_project,
config.GA_SCOPE,
token_header="X-GA-Access-Token",
creds_header="X-GA-Credentials",
)
service_account_info: dict | None = None
raw = (x_ga_credentials or "").strip()
if raw:
try:
decoded = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise MissingCredentialsError(
"X-GA-Credentials is not valid base64."
) from exc
try:
service_account_info = json.loads(decoded)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise MissingCredentialsError(
"X-GA-Credentials does not decode to valid JSON."
) from exc
if not isinstance(service_account_info, dict):
raise MissingCredentialsError(
"X-GA-Credentials JSON must be a service-account object."
)
if not access_token and service_account_info is None:
raise MissingCredentialsError(
"Provide either X-GA-Access-Token or X-GA-Credentials."
)
# --- Google Search Console ----------------------------------------------------
def get_gsc_credentials(
x_gsc_access_token: str | None = Header(
default=None,
alias="X-GSC-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over X-GSC-Credentials.",
),
x_gsc_credentials: str | None = Header(
default=None,
alias="X-GSC-Credentials",
description="Base64-encoded Google service-account JSON key. The proxy "
"mints a short-lived access token from it (scope webmasters.readonly). "
"Used only if X-GSC-Access-Token is absent.",
),
x_gsc_quota_project: str | None = Header(
default=None,
alias="X-GSC-Quota-Project",
description="Optional Google Cloud project id for quota/billing.",
),
) -> GoogleCredentials:
return _build_google_credentials(
x_gsc_access_token,
x_gsc_credentials,
x_gsc_quota_project,
config.GSC_SCOPE,
token_header="X-GSC-Access-Token",
creds_header="X-GSC-Credentials",
)
return GaCredentials(
access_token=access_token,
service_account_info=service_account_info,
quota_project=(x_ga_quota_project or "").strip() or None,
# --- Google Ads ---------------------------------------------------------------
def get_google_ads_credentials(
x_gads_developer_token: str | None = Header(
default=None,
alias="X-GAds-Developer-Token",
description="Google Ads API developer token (from a Google Ads manager "
"account). Required for every Google Ads call.",
),
x_gads_access_token: str | None = Header(
default=None,
alias="X-GAds-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over X-GAds-Credentials.",
),
x_gads_credentials: str | None = Header(
default=None,
alias="X-GAds-Credentials",
description="Base64-encoded Google service-account JSON key (scope "
"adwords). Requires domain-wide delegation; usually an access token is "
"easier. Used only if X-GAds-Access-Token is absent.",
),
x_gads_login_customer_id: str | None = Header(
default=None,
alias="X-GAds-Login-Customer-Id",
description="Optional manager (MCC) customer id used as login-customer-id "
"header. Digits only, no dashes.",
),
x_gads_quota_project: str | None = Header(
default=None,
alias="X-GAds-Quota-Project",
description="Optional Google Cloud project id for quota/billing.",
),
) -> GoogleAdsCredentials:
developer_token = (x_gads_developer_token or "").strip()
if not developer_token:
raise MissingCredentialsError("X-GAds-Developer-Token header is required.")
google = _build_google_credentials(
x_gads_access_token,
x_gads_credentials,
x_gads_quota_project,
config.GOOGLE_ADS_SCOPE,
token_header="X-GAds-Access-Token",
creds_header="X-GAds-Credentials",
)
login_customer_id = (x_gads_login_customer_id or "").strip().replace("-", "") or None
return GoogleAdsCredentials(
google=google,
developer_token=developer_token,
login_customer_id=login_customer_id,
)